@atproto/oauth-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE.txt +7 -0
  3. package/README.md +124 -0
  4. package/dist/constants.d.ts +5 -0
  5. package/dist/constants.d.ts.map +1 -0
  6. package/dist/constants.js +8 -0
  7. package/dist/constants.js.map +1 -0
  8. package/dist/fetch-dpop.d.ts +21 -0
  9. package/dist/fetch-dpop.d.ts.map +1 -0
  10. package/dist/fetch-dpop.js +149 -0
  11. package/dist/fetch-dpop.js.map +1 -0
  12. package/dist/index.d.ts +15 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +35 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/lock.d.ts +2 -0
  17. package/dist/lock.d.ts.map +1 -0
  18. package/dist/lock.js +33 -0
  19. package/dist/lock.js.map +1 -0
  20. package/dist/oauth-agent.d.ts +29 -0
  21. package/dist/oauth-agent.d.ts.map +1 -0
  22. package/dist/oauth-agent.js +138 -0
  23. package/dist/oauth-agent.js.map +1 -0
  24. package/dist/oauth-authorization-server-metadata-resolver.d.ts +15 -0
  25. package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -0
  26. package/dist/oauth-authorization-server-metadata-resolver.js +56 -0
  27. package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -0
  28. package/dist/oauth-callback-error.d.ts +7 -0
  29. package/dist/oauth-callback-error.d.ts.map +1 -0
  30. package/dist/oauth-callback-error.js +28 -0
  31. package/dist/oauth-callback-error.js.map +1 -0
  32. package/dist/oauth-client.d.ts +78 -0
  33. package/dist/oauth-client.d.ts.map +1 -0
  34. package/dist/oauth-client.js +278 -0
  35. package/dist/oauth-client.js.map +1 -0
  36. package/dist/oauth-protected-resource-metadata-resolver.d.ts +15 -0
  37. package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -0
  38. package/dist/oauth-protected-resource-metadata-resolver.js +58 -0
  39. package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -0
  40. package/dist/oauth-resolver-error.d.ts +7 -0
  41. package/dist/oauth-resolver-error.d.ts.map +1 -0
  42. package/dist/oauth-resolver-error.js +17 -0
  43. package/dist/oauth-resolver-error.js.map +1 -0
  44. package/dist/oauth-resolver.d.ts +62 -0
  45. package/dist/oauth-resolver.d.ts.map +1 -0
  46. package/dist/oauth-resolver.js +73 -0
  47. package/dist/oauth-resolver.js.map +1 -0
  48. package/dist/oauth-response-error.d.ts +11 -0
  49. package/dist/oauth-response-error.d.ts.map +1 -0
  50. package/dist/oauth-response-error.js +48 -0
  51. package/dist/oauth-response-error.js.map +1 -0
  52. package/dist/oauth-server-agent.d.ts +51 -0
  53. package/dist/oauth-server-agent.d.ts.map +1 -0
  54. package/dist/oauth-server-agent.js +228 -0
  55. package/dist/oauth-server-agent.js.map +1 -0
  56. package/dist/oauth-server-factory.d.ts +20 -0
  57. package/dist/oauth-server-factory.d.ts.map +1 -0
  58. package/dist/oauth-server-factory.js +53 -0
  59. package/dist/oauth-server-factory.js.map +1 -0
  60. package/dist/refresh-error.d.ts +7 -0
  61. package/dist/refresh-error.d.ts.map +1 -0
  62. package/dist/refresh-error.js +16 -0
  63. package/dist/refresh-error.js.map +1 -0
  64. package/dist/runtime-implementation.d.ts +12 -0
  65. package/dist/runtime-implementation.d.ts.map +1 -0
  66. package/dist/runtime-implementation.js +3 -0
  67. package/dist/runtime-implementation.js.map +1 -0
  68. package/dist/runtime.d.ts +35 -0
  69. package/dist/runtime.d.ts.map +1 -0
  70. package/dist/runtime.js +185 -0
  71. package/dist/runtime.js.map +1 -0
  72. package/dist/session-getter.d.ts +30 -0
  73. package/dist/session-getter.d.ts.map +1 -0
  74. package/dist/session-getter.js +149 -0
  75. package/dist/session-getter.js.map +1 -0
  76. package/dist/types.d.ts +1580 -0
  77. package/dist/types.d.ts.map +1 -0
  78. package/dist/types.js +8 -0
  79. package/dist/types.js.map +1 -0
  80. package/dist/util.d.ts +9 -0
  81. package/dist/util.d.ts.map +1 -0
  82. package/dist/util.js +35 -0
  83. package/dist/util.js.map +1 -0
  84. package/dist/validate-client-metadata.d.ts +5 -0
  85. package/dist/validate-client-metadata.d.ts.map +1 -0
  86. package/dist/validate-client-metadata.js +46 -0
  87. package/dist/validate-client-metadata.js.map +1 -0
  88. package/package.json +46 -0
  89. package/src/constants.ts +4 -0
  90. package/src/fetch-dpop.ts +235 -0
  91. package/src/index.ts +18 -0
  92. package/src/lock.ts +34 -0
  93. package/src/oauth-agent.ts +150 -0
  94. package/src/oauth-authorization-server-metadata-resolver.ts +98 -0
  95. package/src/oauth-callback-error.ts +16 -0
  96. package/src/oauth-client.ts +440 -0
  97. package/src/oauth-protected-resource-metadata-resolver.ts +102 -0
  98. package/src/oauth-resolver-error.ts +12 -0
  99. package/src/oauth-resolver.ts +111 -0
  100. package/src/oauth-response-error.ts +31 -0
  101. package/src/oauth-server-agent.ts +275 -0
  102. package/src/oauth-server-factory.ts +41 -0
  103. package/src/refresh-error.ts +9 -0
  104. package/src/runtime-implementation.ts +17 -0
  105. package/src/runtime.ts +211 -0
  106. package/src/session-getter.ts +182 -0
  107. package/src/types.ts +26 -0
  108. package/src/util.ts +51 -0
  109. package/src/validate-client-metadata.ts +61 -0
  110. package/tsconfig.build.json +8 -0
  111. package/tsconfig.json +4 -0
@@ -0,0 +1,182 @@
1
+ import {
2
+ CachedGetter,
3
+ GetCachedOptions,
4
+ SimpleStore,
5
+ } from '@atproto-labs/simple-store'
6
+ import { Key } from '@atproto/jwk'
7
+ import { OAuthResponseError } from './oauth-response-error.js'
8
+ import { TokenSet } from './oauth-server-agent.js'
9
+ import { OAuthServerFactory } from './oauth-server-factory.js'
10
+ import { RefreshError } from './refresh-error.js'
11
+ import { Runtime } from './runtime.js'
12
+ import { withSignal } from './util.js'
13
+
14
+ export type Session = {
15
+ dpopKey: Key
16
+ tokenSet: TokenSet
17
+ }
18
+
19
+ export type SessionStore = SimpleStore<string, Session>
20
+
21
+ /**
22
+ * There are several advantages to wrapping the sessionStore in a (single)
23
+ * CachedGetter, the main of which is that the cached getter will ensure that at
24
+ * most one fresh call is ever being made. Another advantage, is that it
25
+ * contains the logic for reading from the cache which, if the cache is based on
26
+ * localStorage/indexedDB, will sync across multiple tabs (for a given sub).
27
+ */
28
+ export class SessionGetter extends CachedGetter<string, Session> {
29
+ constructor(
30
+ sessionStore: SessionStore,
31
+ serverFactory: OAuthServerFactory,
32
+ private readonly runtime: Runtime,
33
+ ) {
34
+ super(
35
+ async (sub, options, storedSession) => {
36
+ // There needs to be a previous session to be able to refresh. If
37
+ // storedSession is undefined, it means that the store does not contain
38
+ // a session for the given sub. Since this might have been caused by the
39
+ // value being cleared in another process (e.g. another tab), we will
40
+ // give a chance to the process running this code to detect that the
41
+ // session was revoked. This should allow processes not implementing a
42
+ // subscribe/notify between instances to still be "notified" that the
43
+ // session was revoked.
44
+ if (storedSession === undefined) {
45
+ // Because the session is not in the store, the sessionStore.del
46
+ // function will not be called, even if the "deleteOnError" callback
47
+ // returns true when the error is an "OAuthRefreshError". Let's
48
+ // call it here manually.
49
+ await sessionStore.del(sub)
50
+ throw new RefreshError(sub, 'The session was revoked')
51
+ }
52
+
53
+ if (sub !== storedSession.tokenSet.sub) {
54
+ // Fool-proofing (e.g. against invalid session storage)
55
+ throw new RefreshError(sub, 'Stored session sub mismatch')
56
+ }
57
+
58
+ // Since refresh tokens can only be used once, we might run into
59
+ // concurrency issues if multiple tabs/instances are trying to refresh
60
+ // the same token. The chances of this happening when multiple instances
61
+ // are started simultaneously is reduced by randomizing the expiry time
62
+ // (see isStale() bellow). Even so, There still exist chances that
63
+ // multiple tabs will try to refresh the token at the same time. The
64
+ // best solution would be to use a mutex/lock to ensure that only one
65
+ // instance is refreshing the token at a time. A simpler workaround is
66
+ // to check if the value stored in the session store is the same as the
67
+ // one in memory. If it isn't, then another instance has already
68
+ // refreshed the token.
69
+
70
+ const { tokenSet, dpopKey } = storedSession
71
+ const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
72
+
73
+ // We must not use the "signal" to cancel the refresh or its storage in
74
+ // case of successful refresh. If we obtain a new refresh token, we must
75
+ // ensure that is gets stored in the session store (by returning the new
76
+ // session object). Failing to do so would result in the new credentials
77
+ // being lost.
78
+ options?.signal?.throwIfAborted()
79
+
80
+ const newTokenSet = await server
81
+ .refresh(tokenSet)
82
+ .catch(async (cause) => {
83
+ if (
84
+ cause instanceof OAuthResponseError &&
85
+ cause.status === 400 &&
86
+ cause.error === 'invalid_grant'
87
+ ) {
88
+ // In case there is no lock implementation in the runtime, we will
89
+ // wait for a short time to give the other concurrent instances a
90
+ // chance to finish their refreshing of the token. If a concurrent
91
+ // refresh did occur, we will pretend that this one succeeded.
92
+ if (!runtime.hasLock) {
93
+ await new Promise((r) => setTimeout(r, 1000))
94
+
95
+ const stored = await this.getStored(sub)
96
+ if (stored === undefined) {
97
+ // Using a distinct error message mainly for debugging
98
+ // purposes
99
+ const msg = 'The session was revoked by another process'
100
+ throw new RefreshError(sub, msg, { cause })
101
+ } else if (
102
+ stored.tokenSet.access_token !== tokenSet.access_token ||
103
+ stored.tokenSet.refresh_token !== tokenSet.refresh_token
104
+ ) {
105
+ // A concurrent refresh occurred. Pretend this one succeeded.
106
+ return stored.tokenSet
107
+ } else {
108
+ // There were no concurrent refresh. The token is (likely)
109
+ // simply no longer valid.
110
+ }
111
+ }
112
+
113
+ // Throwing an RefreshError to trigger deletion through the
114
+ // deleteOnError callback.
115
+ const msg = cause.errorDescription ?? 'The session was revoked'
116
+ throw new RefreshError(sub, msg, { cause })
117
+ }
118
+
119
+ throw cause
120
+ })
121
+
122
+ if (sub !== newTokenSet.sub) {
123
+ // The server returned another sub. Was the tokenSet manipulated?
124
+ throw new RefreshError(sub, 'Token set sub mismatch')
125
+ }
126
+
127
+ return { ...storedSession, tokenSet: newTokenSet }
128
+ },
129
+ sessionStore,
130
+ {
131
+ isStale: (sub, { tokenSet }) => {
132
+ return (
133
+ tokenSet.expires_at != null &&
134
+ new Date(tokenSet.expires_at).getTime() <
135
+ // Add some lee way to ensure the token is not expired when it
136
+ // reaches the server.
137
+ Date.now() + 60e3
138
+ )
139
+ },
140
+ onStoreError: async (err, sub, { tokenSet, dpopKey }) => {
141
+ // If the token data cannot be stored, let's revoke it
142
+ const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
143
+ await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
144
+ throw err
145
+ },
146
+ deleteOnError: async (err) => {
147
+ return err instanceof RefreshError
148
+ },
149
+ },
150
+ )
151
+ }
152
+
153
+ /**
154
+ * @param refresh When `true`, the credentials will be refreshed even if they
155
+ * are not expired. When `false`, the credentials will not be refreshed even
156
+ * if they are expired. When `undefined`, the credentials will be refreshed
157
+ * if, and only if, they are (about to be) expired. Defaults to `undefined`.
158
+ */
159
+ async getSession(sub: string, refresh?: boolean) {
160
+ const session = await this.get(sub, {
161
+ noCache: refresh === true,
162
+ allowStale: refresh === false,
163
+ })
164
+
165
+ if (sub !== session.tokenSet.sub) {
166
+ // Fool-proofing (e.g. against invalid session storage)
167
+ throw new Error('Token set does not match the expected sub')
168
+ }
169
+
170
+ return session
171
+ }
172
+
173
+ async get(sub: string, options?: GetCachedOptions): Promise<Session> {
174
+ return this.runtime.withLock(`@atproto-oauth-client-${sub}`, async () => {
175
+ // Make sure, even if there is no signal in the options, that the request
176
+ // will be cancelled after at most 30 seconds.
177
+ return withSignal({ signal: options?.signal, timeout: 30e3 }, (signal) =>
178
+ super.get(sub, { ...options, signal }),
179
+ )
180
+ })
181
+ }
182
+ }
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ import {
2
+ oauthClientIdSchema,
3
+ oauthClientMetadataSchema,
4
+ } from '@atproto/oauth-types'
5
+ import z from 'zod'
6
+
7
+ // Note: These types are not prefixed with `OAuth` because they are not specific
8
+ // to OAuth. They are specific to this packages. OAuth specific types are in
9
+ // `@atproto/oauth-types`.
10
+
11
+ export type AuthorizeOptions = {
12
+ display?: 'page' | 'popup' | 'touch' | 'wap'
13
+ redirect_uri?: string
14
+ id_token_hint?: string
15
+ max_age?: number
16
+ prompt?: 'login' | 'none' | 'consent' | 'select_account'
17
+ scope?: string
18
+ state?: string
19
+ ui_locales?: string
20
+ }
21
+
22
+ export const clientMetadataSchema = oauthClientMetadataSchema.extend({
23
+ client_id: oauthClientIdSchema.url(),
24
+ })
25
+
26
+ export type ClientMetadata = z.infer<typeof clientMetadataSchema>
package/src/util.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @todo (?) move to common package
3
+ */
4
+ export const withSignal = async <T>(
5
+ options:
6
+ | undefined
7
+ | {
8
+ signal?: AbortSignal
9
+ timeout: number
10
+ },
11
+ fn: (signal: AbortSignal) => T | PromiseLike<T>,
12
+ ): Promise<T> => {
13
+ options?.signal?.throwIfAborted()
14
+
15
+ const abortController = new AbortController()
16
+ const { signal } = abortController
17
+
18
+ options?.signal?.addEventListener(
19
+ 'abort',
20
+ (reason) => abortController.abort(reason),
21
+ { once: true, signal },
22
+ )
23
+
24
+ if (options?.timeout != null) {
25
+ const timeoutId = setTimeout(
26
+ (err) => abortController.abort(err),
27
+ options.timeout,
28
+ new Error('Timeout'),
29
+ )
30
+
31
+ timeoutId.unref?.() // NodeJS only
32
+
33
+ signal.addEventListener('abort', () => clearTimeout(timeoutId), {
34
+ once: true,
35
+ signal,
36
+ })
37
+ }
38
+
39
+ try {
40
+ return await fn(signal)
41
+ } finally {
42
+ // - Remove listener on incoming signal
43
+ // - Cancel timeout
44
+ // - Cancel pending (async) tasks
45
+ abortController.abort()
46
+ }
47
+ }
48
+
49
+ export function contentMime(headers: Headers): string | undefined {
50
+ return headers.get('content-type')?.split(';')[0]!.trim()
51
+ }
@@ -0,0 +1,61 @@
1
+ import { Keyset } from '@atproto/jwk'
2
+ import {
3
+ OAUTH_AUTHENTICATED_ENDPOINT_NAMES,
4
+ OAuthClientMetadataInput,
5
+ } from '@atproto/oauth-types'
6
+
7
+ import { ClientMetadata, clientMetadataSchema } from './types.js'
8
+
9
+ // Improve bundle size by using concatenation
10
+ const _ENDPOINT_AUTH_METHOD = '_endpoint_auth_method'
11
+ const _ENDPOINT_AUTH_SIGNING_ALG = '_endpoint_auth_signing_alg'
12
+
13
+ const TOKEN_ENDPOINT_AUTH_METHOD = `token${_ENDPOINT_AUTH_METHOD}`
14
+
15
+ export function validateClientMetadata(
16
+ input: OAuthClientMetadataInput,
17
+ keyset?: Keyset,
18
+ ): ClientMetadata {
19
+ const metadata = clientMetadataSchema.parse(input)
20
+
21
+ // ATPROTO uses client metadata discovery
22
+ try {
23
+ new URL(metadata.client_id)
24
+ } catch (cause) {
25
+ throw new TypeError(`client_id must be a valid URL`, { cause })
26
+ }
27
+
28
+ if (!metadata[TOKEN_ENDPOINT_AUTH_METHOD]) {
29
+ throw new TypeError(`${TOKEN_ENDPOINT_AUTH_METHOD} must be provided`)
30
+ }
31
+
32
+ for (const endpointName of OAUTH_AUTHENTICATED_ENDPOINT_NAMES) {
33
+ const method = metadata[`${endpointName}${_ENDPOINT_AUTH_METHOD}`]
34
+ switch (method) {
35
+ case undefined:
36
+ case 'none':
37
+ if (metadata[`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG}`]) {
38
+ throw new TypeError(
39
+ `${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG} must not be provided`,
40
+ )
41
+ }
42
+ break
43
+ case 'client_secret_jwt':
44
+ if (!keyset) {
45
+ throw new TypeError(`Keyset is required for ${method} method`)
46
+ }
47
+ if (!metadata[`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG}`]) {
48
+ throw new TypeError(
49
+ `${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG} must be provided`,
50
+ )
51
+ }
52
+ break
53
+ default:
54
+ throw new TypeError(
55
+ `Invalid "${endpointName}${_ENDPOINT_AUTH_METHOD}" value: ${method}`,
56
+ )
57
+ }
58
+ }
59
+
60
+ return metadata
61
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["../../../tsconfig/isomorphic.json"],
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": ["./src"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "include": [],
3
+ "references": [{ "path": "./tsconfig.build.json" }]
4
+ }