@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,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
+ }