@atproto/oauth-client 0.7.6 → 0.7.8

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.
@@ -1,541 +0,0 @@
1
- import { Key, Keyset } from '@atproto/jwk'
2
- import {
3
- OAuthAuthorizationRequestParameters,
4
- OAuthClientIdDiscoverable,
5
- OAuthClientMetadata,
6
- OAuthClientMetadataInput,
7
- OAuthResponseMode,
8
- oauthClientMetadataSchema,
9
- } from '@atproto/oauth-types'
10
- import {
11
- AtprotoDid,
12
- DidCache,
13
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
14
- type DidResolverCommonOptions,
15
- assertAtprotoDid,
16
- } from '@atproto-labs/did-resolver'
17
- import { Fetch } from '@atproto-labs/fetch'
18
- import { HandleCache, HandleResolver } from '@atproto-labs/handle-resolver'
19
- import { HANDLE_INVALID } from '@atproto-labs/identity-resolver'
20
- import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
21
- import { FALLBACK_ALG } from './constants.js'
22
- import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
23
- import { TokenRevokedError } from './errors/token-revoked-error.js'
24
- import {
25
- CreateIdentityResolverOptions,
26
- createIdentityResolver,
27
- } from './identity-resolver.js'
28
- import {
29
- AuthorizationServerMetadataCache,
30
- OAuthAuthorizationServerMetadataResolver,
31
- } from './oauth-authorization-server-metadata-resolver.js'
32
- import { OAuthCallbackError } from './oauth-callback-error.js'
33
- import { negotiateClientAuthMethod } from './oauth-client-auth.js'
34
- import {
35
- OAuthProtectedResourceMetadataResolver,
36
- ProtectedResourceMetadataCache,
37
- } from './oauth-protected-resource-metadata-resolver.js'
38
- import { OAuthResolver } from './oauth-resolver.js'
39
- import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
40
- import { OAuthServerFactory } from './oauth-server-factory.js'
41
- import { OAuthSession } from './oauth-session.js'
42
- import { RuntimeImplementation } from './runtime-implementation.js'
43
- import { Runtime } from './runtime.js'
44
- import {
45
- SessionGetter,
46
- SessionHooks,
47
- SessionStore,
48
- isExpectedSessionError,
49
- } from './session-getter.js'
50
- import { InternalStateData, StateStore } from './state-store.js'
51
- import { AuthorizeOptions, CallbackOptions, ClientMetadata } from './types.js'
52
- import { validateClientMetadata } from './validate-client-metadata.js'
53
-
54
- // Export all types needed to construct OAuthClientOptions
55
- export type {
56
- AuthorizationServerMetadataCache,
57
- CreateIdentityResolverOptions,
58
- DidCache,
59
- DpopNonceCache,
60
- Fetch,
61
- HandleCache,
62
- HandleResolver,
63
- InternalStateData,
64
- OAuthClientMetadata,
65
- OAuthClientMetadataInput,
66
- OAuthResponseMode,
67
- ProtectedResourceMetadataCache,
68
- RuntimeImplementation,
69
- SessionHooks,
70
- SessionStore,
71
- StateStore,
72
- }
73
-
74
- export { Key, Keyset }
75
-
76
- export type OAuthClientOptions = {
77
- // Config
78
- responseMode: OAuthResponseMode
79
- clientMetadata: Readonly<OAuthClientMetadataInput>
80
- keyset?: Keyset | Iterable<Key | undefined | null | false>
81
- /**
82
- * Determines if the client will allow communicating with the OAuth Servers
83
- * (Authorization & Resource), or to retrieve "did:web" documents, over
84
- * unsafe HTTP connections. It is recommended to set this to `true` only for
85
- * development purposes.
86
- *
87
- * @note This does not affect the identity resolution mechanism, which will
88
- * allow HTTP connections to the PLC Directory (if the provided directory url
89
- * is "http:" based).
90
- * @default false
91
- * @see {@link OAuthProtectedResourceMetadataResolver.allowHttpResource}
92
- * @see {@link OAuthAuthorizationServerMetadataResolver.allowHttpIssuer}
93
- * @see {@link DidResolverCommonOptions.allowHttp}
94
- */
95
- allowHttp?: boolean
96
-
97
- // Stores
98
- stateStore: StateStore
99
- sessionStore: SessionStore
100
- authorizationServerMetadataCache?: AuthorizationServerMetadataCache
101
- protectedResourceMetadataCache?: ProtectedResourceMetadataCache
102
- dpopNonceCache?: DpopNonceCache
103
-
104
- // Services
105
- runtimeImplementation: RuntimeImplementation
106
- fetch?: Fetch
107
- } & CreateIdentityResolverOptions &
108
- SessionHooks
109
-
110
- export type OAuthClientFetchMetadataOptions = {
111
- clientId: OAuthClientIdDiscoverable
112
- fetch?: Fetch
113
- signal?: AbortSignal
114
- }
115
-
116
- export class OAuthClient {
117
- static async fetchMetadata({
118
- clientId,
119
- fetch = globalThis.fetch,
120
- signal,
121
- }: OAuthClientFetchMetadataOptions) {
122
- signal?.throwIfAborted()
123
-
124
- const request = new Request(clientId, {
125
- redirect: 'error',
126
- signal: signal,
127
- })
128
- const response = await fetch(request)
129
-
130
- if (response.status !== 200) {
131
- response.body?.cancel?.()
132
- throw new TypeError(`Failed to fetch client metadata: ${response.status}`)
133
- }
134
-
135
- // https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html#section-4.1
136
- const mime = response.headers.get('content-type')?.split(';')[0].trim()
137
- if (mime !== 'application/json') {
138
- response.body?.cancel?.()
139
- throw new TypeError(`Invalid client metadata content type: ${mime}`)
140
- }
141
-
142
- const json: unknown = await response.json()
143
-
144
- signal?.throwIfAborted()
145
-
146
- return oauthClientMetadataSchema.parse(json)
147
- }
148
-
149
- // Config
150
- readonly clientMetadata: ClientMetadata
151
- readonly responseMode: OAuthResponseMode
152
- readonly keyset?: Keyset
153
-
154
- // Services
155
- readonly runtime: Runtime
156
- readonly fetch: Fetch
157
- readonly oauthResolver: OAuthResolver
158
- readonly serverFactory: OAuthServerFactory
159
-
160
- // Stores
161
- protected readonly sessionGetter: SessionGetter
162
- protected readonly stateStore: StateStore
163
-
164
- constructor(options: OAuthClientOptions) {
165
- const {
166
- stateStore,
167
- sessionStore,
168
-
169
- dpopNonceCache = new SimpleStoreMemory({ ttl: 60e3, max: 100 }),
170
- authorizationServerMetadataCache = new SimpleStoreMemory({
171
- ttl: 60e3,
172
- max: 100,
173
- }),
174
- protectedResourceMetadataCache = new SimpleStoreMemory({
175
- ttl: 60e3,
176
- max: 100,
177
- }),
178
-
179
- responseMode,
180
- clientMetadata,
181
- runtimeImplementation,
182
- keyset,
183
- } = options
184
-
185
- this.keyset = keyset
186
- ? keyset instanceof Keyset
187
- ? keyset
188
- : new Keyset(keyset)
189
- : undefined
190
- this.clientMetadata = validateClientMetadata(clientMetadata, this.keyset)
191
- this.responseMode = responseMode
192
-
193
- this.runtime = new Runtime(runtimeImplementation)
194
- this.fetch = options.fetch ?? globalThis.fetch
195
- this.oauthResolver = new OAuthResolver(
196
- createIdentityResolver(options),
197
- new OAuthProtectedResourceMetadataResolver(
198
- protectedResourceMetadataCache,
199
- this.fetch,
200
- { allowHttpResource: options.allowHttp },
201
- ),
202
- new OAuthAuthorizationServerMetadataResolver(
203
- authorizationServerMetadataCache,
204
- this.fetch,
205
- { allowHttpIssuer: options.allowHttp },
206
- ),
207
- )
208
- this.serverFactory = new OAuthServerFactory(
209
- this.clientMetadata,
210
- this.runtime,
211
- this.oauthResolver,
212
- this.fetch,
213
- this.keyset,
214
- dpopNonceCache,
215
- )
216
-
217
- this.stateStore = stateStore
218
- this.sessionGetter = new SessionGetter(
219
- sessionStore,
220
- this.serverFactory,
221
- this.runtime,
222
- options,
223
- )
224
- }
225
-
226
- // Exposed as public API for convenience
227
- get identityResolver() {
228
- return this.oauthResolver.identityResolver
229
- }
230
-
231
- get jwks() {
232
- return this.keyset?.publicJwks ?? ({ keys: [] as const } as const)
233
- }
234
-
235
- async authorize(
236
- input: string,
237
- { signal, ...options }: AuthorizeOptions = {},
238
- ): Promise<URL> {
239
- const redirectUri =
240
- options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]
241
- if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
242
- // The server will enforce this, but let's catch it early
243
- throw new TypeError('Invalid redirect_uri')
244
- }
245
-
246
- const { identityInfo, metadata } = await this.oauthResolver.resolve(input, {
247
- signal,
248
- })
249
-
250
- const pkce = await this.runtime.generatePKCE()
251
- const dpopKey = await this.runtime.generateKey(
252
- metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
253
- )
254
-
255
- const authMethod = negotiateClientAuthMethod(
256
- metadata,
257
- this.clientMetadata,
258
- this.keyset,
259
- )
260
- const state = await this.runtime.generateNonce()
261
-
262
- await this.stateStore.set(state, {
263
- iss: metadata.issuer,
264
- dpopKey,
265
- authMethod,
266
- verifier: pkce.verifier,
267
- appState: options?.state,
268
- })
269
-
270
- const parameters: OAuthAuthorizationRequestParameters = {
271
- ...options,
272
-
273
- client_id: this.clientMetadata.client_id,
274
- redirect_uri: redirectUri,
275
- code_challenge: pkce.challenge,
276
- code_challenge_method: pkce.method,
277
- state,
278
- login_hint: identityInfo
279
- ? identityInfo.handle !== HANDLE_INVALID
280
- ? identityInfo.handle
281
- : identityInfo.did
282
- : undefined,
283
- response_mode: this.responseMode,
284
- response_type: 'code' as const,
285
- scope: options?.scope ?? this.clientMetadata.scope,
286
- }
287
-
288
- const authorizationUrl = new URL(metadata.authorization_endpoint)
289
-
290
- // Since the user will be redirected to the authorization_endpoint url using
291
- // a browser, we need to make sure that the url is valid.
292
- if (
293
- authorizationUrl.protocol !== 'https:' &&
294
- authorizationUrl.protocol !== 'http:'
295
- ) {
296
- throw new TypeError(
297
- `Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`,
298
- )
299
- }
300
-
301
- if (metadata.pushed_authorization_request_endpoint) {
302
- const server = await this.serverFactory.fromMetadata(
303
- metadata,
304
- authMethod,
305
- dpopKey,
306
- )
307
- const parResponse = await server.request(
308
- 'pushed_authorization_request',
309
- parameters,
310
- )
311
-
312
- authorizationUrl.searchParams.set(
313
- 'client_id',
314
- this.clientMetadata.client_id,
315
- )
316
- authorizationUrl.searchParams.set('request_uri', parResponse.request_uri)
317
- return authorizationUrl
318
- } else if (metadata.require_pushed_authorization_requests) {
319
- throw new Error(
320
- 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
321
- )
322
- } else {
323
- for (const [key, value] of Object.entries(parameters)) {
324
- if (value) authorizationUrl.searchParams.set(key, String(value))
325
- }
326
-
327
- // Length of the URL that will be sent to the server
328
- const urlLength =
329
- authorizationUrl.pathname.length + authorizationUrl.search.length
330
- if (urlLength < 2048) {
331
- return authorizationUrl
332
- } else if (!metadata.pushed_authorization_request_endpoint) {
333
- throw new Error('Login URL too long')
334
- }
335
- }
336
-
337
- throw new Error(
338
- 'Server does not support pushed authorization requests (PAR)',
339
- )
340
- }
341
-
342
- /**
343
- * This method allows the client to proactively revoke the request_uri it
344
- * created through PAR.
345
- */
346
- async abortRequest(authorizeUrl: URL) {
347
- const requestUri = authorizeUrl.searchParams.get('request_uri')
348
- if (!requestUri) return
349
-
350
- // @NOTE This is not implemented here because, 1) the request server should
351
- // invalidate the request_uri after some delay anyways, and 2) I am not sure
352
- // that the revocation endpoint is even supposed to support this (and I
353
- // don't want to spend the time checking now).
354
-
355
- // @TODO investigate actual necessity & feasibility of this feature
356
- }
357
-
358
- async callback(
359
- params: URLSearchParams,
360
- options: CallbackOptions = {},
361
- ): Promise<{
362
- session: OAuthSession
363
- state: string | null
364
- }> {
365
- const responseJwt = params.get('response')
366
- if (responseJwt != null) {
367
- // https://openid.net/specs/oauth-v2-jarm.html
368
- throw new OAuthCallbackError(params, 'JARM not supported')
369
- }
370
-
371
- const issuerParam = params.get('iss')
372
- const stateParam = params.get('state')
373
- const errorParam = params.get('error')
374
- const codeParam = params.get('code')
375
-
376
- if (!stateParam) {
377
- throw new OAuthCallbackError(params, 'Missing "state" parameter')
378
- }
379
- const stateData = await this.stateStore.get(stateParam)
380
- if (stateData) {
381
- // Prevent any kind of replay
382
- await this.stateStore.del(stateParam)
383
- } else {
384
- throw new OAuthCallbackError(
385
- params,
386
- `Unknown authorization session "${stateParam}"`,
387
- )
388
- }
389
-
390
- try {
391
- if (errorParam != null) {
392
- throw new OAuthCallbackError(params, undefined, stateData.appState)
393
- }
394
-
395
- if (!codeParam) {
396
- throw new OAuthCallbackError(
397
- params,
398
- 'Missing "code" query param',
399
- stateData.appState,
400
- )
401
- }
402
-
403
- const server = await this.serverFactory.fromIssuer(
404
- stateData.iss,
405
- stateData.authMethod,
406
- stateData.dpopKey,
407
- )
408
-
409
- if (issuerParam != null) {
410
- if (!server.issuer) {
411
- throw new OAuthCallbackError(
412
- params,
413
- 'Issuer not found in metadata',
414
- stateData.appState,
415
- )
416
- }
417
- if (server.issuer !== issuerParam) {
418
- throw new OAuthCallbackError(
419
- params,
420
- 'Issuer mismatch',
421
- stateData.appState,
422
- )
423
- }
424
- } else if (
425
- server.serverMetadata.authorization_response_iss_parameter_supported
426
- ) {
427
- throw new OAuthCallbackError(
428
- params,
429
- 'iss missing from the response',
430
- stateData.appState,
431
- )
432
- }
433
-
434
- const tokenSet = await server.exchangeCode(
435
- codeParam,
436
- stateData.verifier,
437
- options?.redirect_uri ?? server.clientMetadata.redirect_uris[0],
438
- )
439
-
440
- // We revoke any existing session first to avoid leaving orphaned sessions
441
- // on the AS.
442
- try {
443
- await this.revoke(tokenSet.sub)
444
- } catch {
445
- // No existing session, or failed to get it. This is fine.
446
- }
447
-
448
- try {
449
- await this.sessionGetter.setStored(tokenSet.sub, {
450
- dpopKey: stateData.dpopKey,
451
- authMethod: server.authMethod,
452
- tokenSet,
453
- })
454
-
455
- const session = this.createSession(server, tokenSet.sub)
456
-
457
- return { session, state: stateData.appState ?? null }
458
- } catch (err) {
459
- await server.revoke(tokenSet.refresh_token || tokenSet.access_token)
460
-
461
- throw err
462
- }
463
- } catch (err) {
464
- // Make sure, whatever the underlying error, that the appState is
465
- // available in the calling code
466
- throw OAuthCallbackError.from(err, params, stateData.appState)
467
- }
468
- }
469
-
470
- /**
471
- * Load a stored session. This will refresh the token only if needed (about to
472
- * expire) by default.
473
- *
474
- * @see {@link SessionGetter.restore}
475
- */
476
- async restore(
477
- sub: string,
478
- refresh: boolean | 'auto' = 'auto',
479
- ): Promise<OAuthSession> {
480
- // sub arg is lightly typed for convenience of library user
481
- assertAtprotoDid(sub)
482
-
483
- const { dpopKey, authMethod, tokenSet } =
484
- await this.sessionGetter.getSession(sub, refresh)
485
-
486
- try {
487
- const server = await this.serverFactory.fromIssuer(
488
- tokenSet.iss,
489
- authMethod,
490
- dpopKey,
491
- {
492
- noCache: refresh === true,
493
- allowStale: refresh === false,
494
- },
495
- )
496
-
497
- return this.createSession(server, sub)
498
- } catch (err) {
499
- if (err instanceof AuthMethodUnsatisfiableError) {
500
- await this.sessionGetter.delStored(sub, err)
501
- }
502
-
503
- throw err
504
- }
505
- }
506
-
507
- async revoke(sub: string) {
508
- // sub arg is lightly typed for convenience of library user
509
- assertAtprotoDid(sub)
510
-
511
- const res = await this.sessionGetter.getSession(sub, false).catch((err) => {
512
- if (isExpectedSessionError(err)) return null
513
- throw err
514
- })
515
-
516
- if (!res) return
517
-
518
- const { dpopKey, authMethod, tokenSet } = res
519
-
520
- // NOT using `;(await this.restore(sub, false)).signOut()` because we want
521
- // the tokens to be deleted even if it was not possible to fetch the issuer
522
- // data.
523
- try {
524
- const server = await this.serverFactory.fromIssuer(
525
- tokenSet.iss,
526
- authMethod,
527
- dpopKey,
528
- )
529
- await server.revoke(tokenSet.access_token)
530
- } finally {
531
- await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
532
- }
533
- }
534
-
535
- protected createSession(
536
- server: OAuthServerAgent,
537
- sub: AtprotoDid,
538
- ): OAuthSession {
539
- return new OAuthSession(server, sub, this.sessionGetter, this.fetch)
540
- }
541
- }
@@ -1,122 +0,0 @@
1
- import {
2
- OAuthProtectedResourceMetadata,
3
- oauthProtectedResourceMetadataSchema,
4
- } from '@atproto/oauth-types'
5
- import {
6
- Fetch,
7
- FetchResponseError,
8
- bindFetch,
9
- cancelBody,
10
- } from '@atproto-labs/fetch'
11
- import {
12
- CachedGetter,
13
- GetCachedOptions,
14
- SimpleStore,
15
- } from '@atproto-labs/simple-store'
16
- import { contentMime } from './util.js'
17
-
18
- export type { GetCachedOptions, OAuthProtectedResourceMetadata }
19
-
20
- export type ProtectedResourceMetadataCache = SimpleStore<
21
- string,
22
- OAuthProtectedResourceMetadata | null
23
- >
24
-
25
- export type OAuthProtectedResourceMetadataResolverConfig = {
26
- allowHttpResource?: boolean
27
- }
28
-
29
- /**
30
- * @see {@link https://www.rfc-editor.org/rfc/rfc9728.html}
31
- */
32
- export class OAuthProtectedResourceMetadataResolver extends CachedGetter<
33
- string,
34
- OAuthProtectedResourceMetadata | null
35
- > {
36
- private readonly fetch: Fetch<unknown>
37
- private readonly allowHttpResource: boolean
38
-
39
- constructor(
40
- cache: ProtectedResourceMetadataCache,
41
- fetch: Fetch = globalThis.fetch,
42
- config?: OAuthProtectedResourceMetadataResolverConfig,
43
- ) {
44
- super(async (origin, options) => this.fetchMetadata(origin, options), cache)
45
-
46
- this.fetch = bindFetch(fetch)
47
- this.allowHttpResource = config?.allowHttpResource === true
48
- }
49
-
50
- async get(
51
- resource: string | URL,
52
- options?: GetCachedOptions,
53
- ): Promise<OAuthProtectedResourceMetadata | null> {
54
- const { protocol, origin } = new URL(resource)
55
-
56
- if (protocol !== 'https:' && protocol !== 'http:') {
57
- throw new TypeError(
58
- `Invalid protected resource metadata URL protocol: ${protocol}`,
59
- )
60
- }
61
-
62
- if (protocol === 'http:' && !this.allowHttpResource) {
63
- throw new TypeError(
64
- `Unsecure resource metadata URL (${protocol}) only allowed in development and test environments`,
65
- )
66
- }
67
-
68
- return super.get(origin, options)
69
- }
70
-
71
- private async fetchMetadata(
72
- origin: string,
73
- options?: GetCachedOptions,
74
- ): Promise<OAuthProtectedResourceMetadata | null> {
75
- const url = new URL(`/.well-known/oauth-protected-resource`, origin)
76
- const request = new Request(url, {
77
- signal: options?.signal,
78
- headers: { accept: 'application/json' },
79
- cache: options?.noCache ? 'no-cache' : undefined,
80
- redirect: 'manual', // response must be 200 OK
81
- })
82
-
83
- const response = await this.fetch(request)
84
-
85
- if (response.status === 404) {
86
- await cancelBody(response, 'log')
87
- return null
88
- }
89
-
90
- // https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2
91
- if (response.status !== 200) {
92
- await cancelBody(response, 'log')
93
- throw await FetchResponseError.from(
94
- response,
95
- `Unexpected status code ${response.status} for "${url}"`,
96
- undefined,
97
- { cause: request },
98
- )
99
- }
100
-
101
- if (contentMime(response.headers) !== 'application/json') {
102
- await cancelBody(response, 'log')
103
- throw await FetchResponseError.from(
104
- response,
105
- `Unexpected content type for "${url}"`,
106
- undefined,
107
- { cause: request },
108
- )
109
- }
110
-
111
- const metadata = oauthProtectedResourceMetadataSchema.parse(
112
- await response.json(),
113
- )
114
-
115
- // https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3
116
- if (metadata.resource !== origin) {
117
- throw new TypeError(`Invalid issuer ${metadata.resource}`)
118
- }
119
-
120
- return metadata
121
- }
122
- }
@@ -1,21 +0,0 @@
1
- import { ZodError } from 'zod'
2
-
3
- export class OAuthResolverError extends Error {
4
- constructor(message: string, options?: { cause?: unknown }) {
5
- super(message, options)
6
- }
7
-
8
- static from(cause: unknown, message?: string): OAuthResolverError {
9
- if (cause instanceof OAuthResolverError) return cause
10
- const validationReason =
11
- cause instanceof ZodError
12
- ? `${cause.errors[0].path} ${cause.errors[0].message}`
13
- : null
14
- const fullMessage =
15
- (message ?? `Unable to resolve identity`) +
16
- (validationReason ? ` (${validationReason})` : '')
17
- return new OAuthResolverError(fullMessage, {
18
- cause,
19
- })
20
- }
21
- }