@atproto/oauth-client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,98 @@
1
+ import {
2
+ bindFetch,
3
+ cancelBody,
4
+ Fetch,
5
+ FetchResponseError,
6
+ } from '@atproto-labs/fetch'
7
+ import {
8
+ CachedGetter,
9
+ GetCachedOptions,
10
+ SimpleStore,
11
+ } from '@atproto-labs/simple-store'
12
+ import {
13
+ OAuthAuthorizationServerMetadata,
14
+ oauthAuthorizationServerMetadataValidator,
15
+ oauthIssuerIdentifierSchema,
16
+ } from '@atproto/oauth-types'
17
+ import { contentMime } from './util'
18
+
19
+ export type { GetCachedOptions, OAuthAuthorizationServerMetadata }
20
+
21
+ export type AuthorizationServerMetadataCache = SimpleStore<
22
+ string,
23
+ OAuthAuthorizationServerMetadata
24
+ >
25
+
26
+ /**
27
+ * @see {@link https://datatracker.ietf.org/doc/html/rfc8414}
28
+ */
29
+ export class OAuthAuthorizationServerMetadataResolver extends CachedGetter<
30
+ string,
31
+ OAuthAuthorizationServerMetadata
32
+ > {
33
+ private readonly fetch: Fetch<unknown>
34
+
35
+ constructor(cache: AuthorizationServerMetadataCache, fetch?: Fetch) {
36
+ super(async (issuer, options) => this.fetchMetadata(issuer, options), cache)
37
+
38
+ this.fetch = bindFetch(fetch)
39
+ }
40
+
41
+ async get(
42
+ issuer: string,
43
+ options?: GetCachedOptions,
44
+ ): Promise<OAuthAuthorizationServerMetadata> {
45
+ return super.get(oauthIssuerIdentifierSchema.parse(issuer), options)
46
+ }
47
+
48
+ private async fetchMetadata(
49
+ issuer: string,
50
+ options?: GetCachedOptions,
51
+ ): Promise<OAuthAuthorizationServerMetadata> {
52
+ const headers = new Headers([['accept', 'application/json']])
53
+ if (options?.noCache) headers.set('cache-control', 'no-cache')
54
+
55
+ const url = new URL(`/.well-known/oauth-authorization-server`, issuer)
56
+ const request = new Request(url, {
57
+ signal: options?.signal,
58
+ headers,
59
+ redirect: 'manual', // response must be 200 OK
60
+ })
61
+
62
+ const response = await this.fetch(request)
63
+
64
+ // https://datatracker.ietf.org/doc/html/rfc8414#section-3.2
65
+ if (response.status !== 200) {
66
+ await cancelBody(response, 'log')
67
+ throw await FetchResponseError.from(
68
+ response,
69
+ `Unexpected status code ${response.status} for "${url}"`,
70
+ undefined,
71
+ { cause: request },
72
+ )
73
+ }
74
+
75
+ if (contentMime(response.headers) !== 'application/json') {
76
+ await cancelBody(response, 'log')
77
+ throw await FetchResponseError.from(
78
+ response,
79
+ `Unexpected content type for "${url}"`,
80
+ undefined,
81
+ { cause: request },
82
+ )
83
+ }
84
+
85
+ const metadata = oauthAuthorizationServerMetadataValidator.parse(
86
+ await response.json(),
87
+ )
88
+
89
+ // Validate the issuer (MIX-UP attacks)
90
+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks
91
+ // https://datatracker.ietf.org/doc/html/rfc8414#section-2
92
+ if (metadata.issuer !== issuer) {
93
+ throw new TypeError(`Invalid issuer ${metadata.issuer}`)
94
+ }
95
+
96
+ return metadata
97
+ }
98
+ }
@@ -0,0 +1,16 @@
1
+ export class OAuthCallbackError extends Error {
2
+ static from(err: unknown, params: URLSearchParams, state?: string) {
3
+ if (err instanceof OAuthCallbackError) return err
4
+ const message = err instanceof Error ? err.message : undefined
5
+ return new OAuthCallbackError(params, message, state, err)
6
+ }
7
+
8
+ constructor(
9
+ public readonly params: URLSearchParams,
10
+ message = params.get('error_description') || 'OAuth callback error',
11
+ public readonly state?: string,
12
+ cause?: unknown,
13
+ ) {
14
+ super(message, { cause })
15
+ }
16
+ }
@@ -0,0 +1,440 @@
1
+ import {
2
+ DidCache,
3
+ DidResolverCached,
4
+ DidResolverCommon,
5
+ } from '@atproto-labs/did-resolver'
6
+ import { Fetch } from '@atproto-labs/fetch'
7
+ import {
8
+ AppViewHandleResolver,
9
+ CachedHandleResolver,
10
+ HandleCache,
11
+ HandleResolver,
12
+ } from '@atproto-labs/handle-resolver'
13
+ import { IdentityResolver } from '@atproto-labs/identity-resolver'
14
+ import { SimpleStore } from '@atproto-labs/simple-store'
15
+ import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
16
+ import { Key, Keyset } from '@atproto/jwk'
17
+ import {
18
+ OAuthClientMetadata,
19
+ OAuthClientMetadataInput,
20
+ OAuthResponseMode,
21
+ } from '@atproto/oauth-types'
22
+
23
+ import { FALLBACK_ALG } from './constants.js'
24
+ import { OAuthAgent } from './oauth-agent.js'
25
+ import {
26
+ AuthorizationServerMetadataCache,
27
+ OAuthAuthorizationServerMetadataResolver,
28
+ } from './oauth-authorization-server-metadata-resolver.js'
29
+ import { OAuthCallbackError } from './oauth-callback-error.js'
30
+ import {
31
+ OAuthProtectedResourceMetadataResolver,
32
+ ProtectedResourceMetadataCache,
33
+ } from './oauth-protected-resource-metadata-resolver.js'
34
+ import { OAuthResolver } from './oauth-resolver.js'
35
+ import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
36
+ import { OAuthServerFactory } from './oauth-server-factory.js'
37
+ import { RuntimeImplementation } from './runtime-implementation.js'
38
+ import { Runtime } from './runtime.js'
39
+ import { SessionGetter, SessionStore } from './session-getter.js'
40
+ import { AuthorizeOptions, ClientMetadata } from './types.js'
41
+ import { validateClientMetadata } from './validate-client-metadata.js'
42
+
43
+ export type InternalStateData = {
44
+ iss: string
45
+ nonce: string
46
+ dpopKey: Key
47
+ verifier?: string
48
+
49
+ /**
50
+ * @note This could be parametrized to be of any type. This wasn't done for
51
+ * the sake of simplicity but could be added in a later development.
52
+ */
53
+ appState?: string
54
+ }
55
+
56
+ export type StateStore = SimpleStore<string, InternalStateData>
57
+
58
+ // Export all types needed to construct OAuthClientOptions
59
+ export type {
60
+ AuthorizationServerMetadataCache,
61
+ DpopNonceCache,
62
+ Fetch,
63
+ Keyset,
64
+ OAuthClientMetadata,
65
+ OAuthClientMetadataInput,
66
+ OAuthResponseMode,
67
+ ProtectedResourceMetadataCache,
68
+ RuntimeImplementation,
69
+ SessionStore,
70
+ }
71
+
72
+ export type OAuthClientOptions = {
73
+ // Config
74
+ responseMode: OAuthResponseMode
75
+ clientMetadata: Readonly<OAuthClientMetadataInput>
76
+ keyset?: Keyset | Iterable<Key | undefined | null | false>
77
+
78
+ // Stores
79
+ stateStore: StateStore
80
+ sessionStore: SessionStore
81
+ didCache?: DidCache
82
+ handleCache?: HandleCache
83
+ authorizationServerMetadataCache?: AuthorizationServerMetadataCache
84
+ protectedResourceMetadataCache?: ProtectedResourceMetadataCache
85
+ dpopNonceCache?: DpopNonceCache
86
+
87
+ // Services
88
+ handleResolver: HandleResolver | URL | string
89
+ plcDirectoryUrl?: URL | string
90
+ runtimeImplementation: RuntimeImplementation
91
+ fetch?: Fetch
92
+ }
93
+
94
+ export class OAuthClient {
95
+ // Config
96
+ readonly clientMetadata: ClientMetadata
97
+ readonly responseMode: OAuthResponseMode
98
+ readonly keyset?: Keyset
99
+
100
+ // Services
101
+ readonly runtime: Runtime
102
+ readonly fetch: Fetch
103
+ readonly oauthResolver: OAuthResolver
104
+ readonly serverFactory: OAuthServerFactory
105
+
106
+ // Stores
107
+ readonly sessionGetter: SessionGetter
108
+ readonly stateStore: StateStore
109
+
110
+ constructor({
111
+ fetch = globalThis.fetch,
112
+
113
+ stateStore,
114
+ sessionStore,
115
+
116
+ didCache = undefined,
117
+ dpopNonceCache = new SimpleStoreMemory({ ttl: 60e3, max: 100 }),
118
+ handleCache = undefined,
119
+ authorizationServerMetadataCache = new SimpleStoreMemory({
120
+ ttl: 60e3,
121
+ max: 100,
122
+ }),
123
+ protectedResourceMetadataCache = new SimpleStoreMemory({
124
+ ttl: 60e3,
125
+ max: 100,
126
+ }),
127
+
128
+ responseMode,
129
+ clientMetadata,
130
+ handleResolver,
131
+ plcDirectoryUrl,
132
+ runtimeImplementation,
133
+ keyset,
134
+ }: OAuthClientOptions) {
135
+ this.keyset = keyset
136
+ ? keyset instanceof Keyset
137
+ ? keyset
138
+ : new Keyset(keyset)
139
+ : undefined
140
+ this.clientMetadata = validateClientMetadata(clientMetadata, this.keyset)
141
+ this.responseMode = responseMode
142
+
143
+ this.runtime = new Runtime(runtimeImplementation)
144
+ this.fetch = fetch
145
+ this.oauthResolver = new OAuthResolver(
146
+ new IdentityResolver(
147
+ new DidResolverCached(
148
+ new DidResolverCommon({ fetch, plcDirectoryUrl }),
149
+ didCache,
150
+ ),
151
+ new CachedHandleResolver(
152
+ AppViewHandleResolver.from(handleResolver, { fetch }),
153
+ handleCache,
154
+ ),
155
+ ),
156
+ new OAuthProtectedResourceMetadataResolver(
157
+ protectedResourceMetadataCache,
158
+ fetch,
159
+ ),
160
+ new OAuthAuthorizationServerMetadataResolver(
161
+ authorizationServerMetadataCache,
162
+ fetch,
163
+ ),
164
+ )
165
+ this.serverFactory = new OAuthServerFactory(
166
+ this.clientMetadata,
167
+ this.runtime,
168
+ this.oauthResolver,
169
+ this.fetch,
170
+ this.keyset,
171
+ dpopNonceCache,
172
+ )
173
+
174
+ this.sessionGetter = new SessionGetter(
175
+ sessionStore,
176
+ this.serverFactory,
177
+ this.runtime,
178
+ )
179
+ this.stateStore = stateStore
180
+ }
181
+
182
+ // Exposed as public API for convenience
183
+ get identityResolver() {
184
+ return this.oauthResolver.identityResolver
185
+ }
186
+
187
+ // Exposed as public API for convenience
188
+ get didResolver() {
189
+ return this.identityResolver.didResolver
190
+ }
191
+
192
+ // Exposed as public API for convenience
193
+ get handleResolver() {
194
+ return this.identityResolver.handleResolver
195
+ }
196
+
197
+ async authorize(
198
+ input: string,
199
+ options?: AuthorizeOptions & { signal?: AbortSignal },
200
+ ): Promise<URL> {
201
+ const redirectUri =
202
+ options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]
203
+ if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
204
+ // The server will enforce this, but let's catch it early
205
+ throw new TypeError('Invalid redirect_uri')
206
+ }
207
+
208
+ const signal = options?.signal
209
+ const { identity, metadata } = /^https?:\/\//.test(input)
210
+ ? // Allow using an entryway url directly as login input (e.g. when the
211
+ // user forgot their handle, or when the handle does not resolve to a
212
+ // DID)
213
+ {
214
+ identity: undefined,
215
+ metadata: await this.oauthResolver.resolveMetadata(input, { signal }),
216
+ }
217
+ : await this.oauthResolver.resolve(input, { signal })
218
+
219
+ const nonce = await this.runtime.generateNonce()
220
+ const pkce = await this.runtime.generatePKCE()
221
+ const dpopKey = await this.runtime.generateKey(
222
+ metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
223
+ )
224
+
225
+ const state = await this.runtime.generateNonce()
226
+
227
+ await this.stateStore.set(state, {
228
+ iss: metadata.issuer,
229
+ dpopKey,
230
+ nonce,
231
+ verifier: pkce?.verifier,
232
+ appState: options?.state,
233
+ })
234
+
235
+ const parameters = {
236
+ client_id: this.clientMetadata.client_id,
237
+ redirect_uri: redirectUri,
238
+ code_challenge: pkce?.challenge,
239
+ code_challenge_method: pkce?.method,
240
+ nonce,
241
+ state,
242
+ login_hint: identity?.did || undefined,
243
+ response_mode: this.responseMode,
244
+ response_type:
245
+ // Negotiate by using the order in the client metadata
246
+ this.clientMetadata.response_types?.find((t) =>
247
+ metadata['response_types_supported']?.includes(t),
248
+ ) ?? 'code',
249
+
250
+ display: options?.display,
251
+ id_token_hint: options?.id_token_hint,
252
+ max_age: options?.max_age, // this.clientMetadata.default_max_age
253
+ prompt: options?.prompt,
254
+ scope: options?.scope
255
+ ?.split(' ')
256
+ .filter((s) => metadata.scopes_supported?.includes(s))
257
+ .join(' '),
258
+ ui_locales: options?.ui_locales,
259
+ }
260
+
261
+ if (metadata.pushed_authorization_request_endpoint) {
262
+ const server = await this.serverFactory.fromMetadata(metadata, dpopKey)
263
+ const parResponse = await server.request(
264
+ 'pushed_authorization_request',
265
+ parameters,
266
+ )
267
+
268
+ const authorizationUrl = new URL(metadata.authorization_endpoint)
269
+ authorizationUrl.searchParams.set(
270
+ 'client_id',
271
+ this.clientMetadata.client_id,
272
+ )
273
+ authorizationUrl.searchParams.set('request_uri', parResponse.request_uri)
274
+ return authorizationUrl
275
+ } else if (metadata.require_pushed_authorization_requests) {
276
+ throw new Error(
277
+ 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
278
+ )
279
+ } else {
280
+ const authorizationUrl = new URL(metadata.authorization_endpoint)
281
+ for (const [key, value] of Object.entries(parameters)) {
282
+ if (value) authorizationUrl.searchParams.set(key, String(value))
283
+ }
284
+
285
+ // Length of the URL that will be sent to the server
286
+ const urlLength =
287
+ authorizationUrl.pathname.length + authorizationUrl.search.length
288
+ if (urlLength < 2048) {
289
+ return authorizationUrl
290
+ } else if (!metadata.pushed_authorization_request_endpoint) {
291
+ throw new Error('Login URL too long')
292
+ }
293
+ }
294
+
295
+ throw new Error(
296
+ 'Server does not support pushed authorization requests (PAR)',
297
+ )
298
+ }
299
+
300
+ async callback(params: URLSearchParams): Promise<{
301
+ agent: OAuthAgent
302
+ state: string | null
303
+ }> {
304
+ const responseJwt = params.get('response')
305
+ if (responseJwt != null) {
306
+ // https://openid.net/specs/oauth-v2-jarm.html
307
+ throw new OAuthCallbackError(params, 'JARM not supported')
308
+ }
309
+
310
+ const issuerParam = params.get('iss')
311
+ const stateParam = params.get('state')
312
+ const errorParam = params.get('error')
313
+ const codeParam = params.get('code')
314
+
315
+ if (!stateParam) {
316
+ throw new OAuthCallbackError(params, 'Missing "state" parameter')
317
+ }
318
+ const stateData = await this.stateStore.get(stateParam)
319
+ if (stateData) {
320
+ // Prevent any kind of replay
321
+ await this.stateStore.del(stateParam)
322
+ } else {
323
+ throw new OAuthCallbackError(
324
+ params,
325
+ `Unknown authorization session "${stateParam}"`,
326
+ )
327
+ }
328
+
329
+ try {
330
+ if (errorParam != null) {
331
+ throw new OAuthCallbackError(params, undefined, stateData.appState)
332
+ }
333
+
334
+ if (!codeParam) {
335
+ throw new OAuthCallbackError(
336
+ params,
337
+ 'Missing "code" query param',
338
+ stateData.appState,
339
+ )
340
+ }
341
+
342
+ const server = await this.serverFactory.fromIssuer(
343
+ stateData.iss,
344
+ stateData.dpopKey,
345
+ )
346
+
347
+ if (issuerParam != null) {
348
+ if (!server.serverMetadata.issuer) {
349
+ throw new OAuthCallbackError(
350
+ params,
351
+ 'Issuer not found in metadata',
352
+ stateData.appState,
353
+ )
354
+ }
355
+ if (server.serverMetadata.issuer !== issuerParam) {
356
+ throw new OAuthCallbackError(
357
+ params,
358
+ 'Issuer mismatch',
359
+ stateData.appState,
360
+ )
361
+ }
362
+ } else if (
363
+ server.serverMetadata.authorization_response_iss_parameter_supported
364
+ ) {
365
+ throw new OAuthCallbackError(
366
+ params,
367
+ 'iss missing from the response',
368
+ stateData.appState,
369
+ )
370
+ }
371
+
372
+ const tokenSet = await server.exchangeCode(codeParam, stateData.verifier)
373
+ try {
374
+ if (tokenSet.id_token) {
375
+ await this.runtime.validateIdTokenClaims(
376
+ tokenSet.id_token,
377
+ stateParam,
378
+ stateData.nonce,
379
+ codeParam,
380
+ tokenSet.access_token,
381
+ )
382
+ }
383
+
384
+ const { sub } = tokenSet
385
+
386
+ await this.sessionGetter.setStored(sub, {
387
+ dpopKey: stateData.dpopKey,
388
+ tokenSet,
389
+ })
390
+
391
+ const agent = this.createAgent(server, sub)
392
+
393
+ return { agent, state: stateData.appState ?? null }
394
+ } catch (err) {
395
+ await server.revoke(tokenSet.access_token)
396
+
397
+ throw err
398
+ }
399
+ } catch (err) {
400
+ // Make sure, whatever the underlying error, that the appState is
401
+ // available in the calling code
402
+ throw OAuthCallbackError.from(err, params, stateData.appState)
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Build an agent from a stored session. This will refresh the token only if
408
+ * needed (about to expire) by default.
409
+ *
410
+ * @param refresh See {@link SessionGetter.getSession}
411
+ */
412
+ async restore(sub: string, refresh?: boolean): Promise<OAuthAgent> {
413
+ const { dpopKey, tokenSet } = await this.sessionGetter.getSession(
414
+ sub,
415
+ refresh,
416
+ )
417
+
418
+ const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, {
419
+ noCache: refresh === true,
420
+ allowStale: refresh === false,
421
+ })
422
+
423
+ return this.createAgent(server, sub)
424
+ }
425
+
426
+ async revoke(sub: string) {
427
+ const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
428
+ allowStale: true,
429
+ })
430
+
431
+ const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
432
+
433
+ await server.revoke(tokenSet.access_token)
434
+ await this.sessionGetter.delStored(sub)
435
+ }
436
+
437
+ createAgent(server: OAuthServerAgent, sub: string): OAuthAgent {
438
+ return new OAuthAgent(server, sub, this.sessionGetter, this.fetch)
439
+ }
440
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ Fetch,
3
+ FetchResponseError,
4
+ bindFetch,
5
+ cancelBody,
6
+ } from '@atproto-labs/fetch'
7
+ import {
8
+ CachedGetter,
9
+ GetCachedOptions,
10
+ SimpleStore,
11
+ } from '@atproto-labs/simple-store'
12
+ import {
13
+ OAuthProtectedResourceMetadata,
14
+ oauthProtectedResourceMetadataSchema,
15
+ } from '@atproto/oauth-types'
16
+ import { contentMime } from './util'
17
+
18
+ export type { GetCachedOptions, OAuthProtectedResourceMetadata }
19
+
20
+ export type ProtectedResourceMetadataCache = SimpleStore<
21
+ string,
22
+ OAuthProtectedResourceMetadata
23
+ >
24
+
25
+ /**
26
+ * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05}
27
+ */
28
+ export class OAuthProtectedResourceMetadataResolver extends CachedGetter<
29
+ string,
30
+ OAuthProtectedResourceMetadata
31
+ > {
32
+ private readonly fetch: Fetch<unknown>
33
+
34
+ constructor(
35
+ cache: ProtectedResourceMetadataCache,
36
+ fetch: Fetch = globalThis.fetch,
37
+ ) {
38
+ super(async (origin, options) => this.fetchMetadata(origin, options), cache)
39
+
40
+ this.fetch = bindFetch(fetch)
41
+ }
42
+
43
+ async get(
44
+ resource: string | URL,
45
+ options?: GetCachedOptions,
46
+ ): Promise<OAuthProtectedResourceMetadata> {
47
+ const { protocol, origin } = new URL(resource)
48
+ if (protocol !== 'https:' && protocol !== 'http:') {
49
+ throw new TypeError(`Invalid resource server ${protocol}`)
50
+ }
51
+ return super.get(origin, options)
52
+ }
53
+
54
+ private async fetchMetadata(
55
+ origin: string,
56
+ options?: GetCachedOptions,
57
+ ): Promise<OAuthProtectedResourceMetadata> {
58
+ const headers = new Headers([['accept', 'application/json']])
59
+ if (options?.noCache) headers.set('cache-control', 'no-cache')
60
+
61
+ const url = new URL(`/.well-known/oauth-protected-resource`, origin)
62
+ const request = new Request(url, {
63
+ signal: options?.signal,
64
+ headers,
65
+ redirect: 'error', // response must be 200 OK
66
+ })
67
+
68
+ const response = await this.fetch(request)
69
+
70
+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.2
71
+ if (response.status !== 200) {
72
+ await cancelBody(response, 'log')
73
+ throw await FetchResponseError.from(
74
+ response,
75
+ `Unexpected status code ${response.status} for "${url}"`,
76
+ undefined,
77
+ { cause: request },
78
+ )
79
+ }
80
+
81
+ if (contentMime(response.headers) !== 'application/json') {
82
+ await cancelBody(response, 'log')
83
+ throw await FetchResponseError.from(
84
+ response,
85
+ `Unexpected content type for "${url}"`,
86
+ undefined,
87
+ { cause: request },
88
+ )
89
+ }
90
+
91
+ const metadata = oauthProtectedResourceMetadataSchema.parse(
92
+ await response.json(),
93
+ )
94
+
95
+ // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.3
96
+ if (metadata.resource !== origin) {
97
+ throw new TypeError(`Invalid issuer ${metadata.resource}`)
98
+ }
99
+
100
+ return metadata
101
+ }
102
+ }
@@ -0,0 +1,12 @@
1
+ export class OAuthResolverError extends Error {
2
+ constructor(message: string, options?: { cause?: unknown }) {
3
+ super(message, options)
4
+ }
5
+
6
+ static from(cause: unknown, message?: string): OAuthResolverError {
7
+ if (cause instanceof OAuthResolverError) return cause
8
+ return new OAuthResolverError(message ?? `Unable to resolve identity`, {
9
+ cause,
10
+ })
11
+ }
12
+ }