@atproto/oauth-client 0.3.22 → 0.4.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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/errors/auth-method-unsatisfiable-error.d.ts +3 -0
  3. package/dist/errors/auth-method-unsatisfiable-error.d.ts.map +1 -0
  4. package/dist/errors/auth-method-unsatisfiable-error.js +7 -0
  5. package/dist/errors/auth-method-unsatisfiable-error.js.map +1 -0
  6. package/dist/oauth-client-auth.d.ts +23 -0
  7. package/dist/oauth-client-auth.d.ts.map +1 -0
  8. package/dist/oauth-client-auth.js +131 -0
  9. package/dist/oauth-client-auth.js.map +1 -0
  10. package/dist/oauth-client.d.ts +4 -4
  11. package/dist/oauth-client.d.ts.map +1 -1
  12. package/dist/oauth-client.js +25 -10
  13. package/dist/oauth-client.js.map +1 -1
  14. package/dist/oauth-resolver.d.ts +1 -1
  15. package/dist/oauth-server-agent.d.ts +8 -6
  16. package/dist/oauth-server-agent.d.ts.map +1 -1
  17. package/dist/oauth-server-agent.js +19 -50
  18. package/dist/oauth-server-agent.js.map +1 -1
  19. package/dist/oauth-server-factory.d.ts +15 -2
  20. package/dist/oauth-server-factory.d.ts.map +1 -1
  21. package/dist/oauth-server-factory.js +23 -4
  22. package/dist/oauth-server-factory.js.map +1 -1
  23. package/dist/session-getter.d.ts +5 -0
  24. package/dist/session-getter.d.ts.map +1 -1
  25. package/dist/session-getter.js +24 -11
  26. package/dist/session-getter.js.map +1 -1
  27. package/dist/state-store.d.ts +3 -0
  28. package/dist/state-store.d.ts.map +1 -1
  29. package/dist/types.d.ts +8 -8
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/validate-client-metadata.d.ts.map +1 -1
  32. package/dist/validate-client-metadata.js +32 -26
  33. package/dist/validate-client-metadata.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/errors/auth-method-unsatisfiable-error.ts +1 -0
  36. package/src/oauth-client-auth.ts +182 -0
  37. package/src/oauth-client.ts +49 -9
  38. package/src/oauth-server-agent.ts +19 -71
  39. package/src/oauth-server-factory.ts +37 -2
  40. package/src/session-getter.ts +43 -10
  41. package/src/state-store.ts +3 -0
  42. package/src/validate-client-metadata.ts +40 -27
  43. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,182 @@
1
+ import { Keyset } from '@atproto/jwk'
2
+ import {
3
+ CLIENT_ASSERTION_TYPE_JWT_BEARER,
4
+ OAuthAuthorizationServerMetadata,
5
+ OAuthClientCredentials,
6
+ } from '@atproto/oauth-types'
7
+ import { FALLBACK_ALG } from './constants.js'
8
+ import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
9
+ import { Runtime } from './runtime.js'
10
+ import { ClientMetadata } from './types.js'
11
+ import { Awaitable } from './util.js'
12
+
13
+ export type ClientAuthMethod =
14
+ | { method: 'none' }
15
+ | { method: 'private_key_jwt'; kid: string }
16
+
17
+ export function negotiateClientAuthMethod(
18
+ serverMetadata: OAuthAuthorizationServerMetadata,
19
+ clientMetadata: ClientMetadata,
20
+ keyset?: Keyset,
21
+ ): ClientAuthMethod {
22
+ const method = clientMetadata.token_endpoint_auth_method
23
+
24
+ // @NOTE ATproto spec requires that AS support both "none" and
25
+ // "private_key_jwt", and that clients use one of the other. The following
26
+ // check ensures that the AS is indeed compliant with this client's
27
+ // configuration.
28
+ const methods = supportedMethods(serverMetadata)
29
+ if (!methods.includes(method)) {
30
+ throw new Error(
31
+ `The server does not support "${method}" authentication. Supported methods are: ${methods.join(
32
+ ', ',
33
+ )}.`,
34
+ )
35
+ }
36
+
37
+ if (method === 'private_key_jwt') {
38
+ // Invalid client configuration. This should not happen as
39
+ // "validateClientMetadata" already check this.
40
+ if (!keyset) throw new Error('A keyset is required for private_key_jwt')
41
+
42
+ const alg = supportedAlgs(serverMetadata)
43
+
44
+ // @NOTE we can't use `keyset.findPrivateKey` here because we can't enforce
45
+ // that the returned key contains a "kid". The following implementation is
46
+ // more robust against keysets containing keys without a "kid" property.
47
+ for (const key of keyset.list({ use: 'sig', alg })) {
48
+ // Return the first key from the key set that matches the server's
49
+ // supported algorithms.
50
+ if (key.isPrivate && key.kid) {
51
+ return { method: 'private_key_jwt', kid: key.kid }
52
+ }
53
+ }
54
+
55
+ throw new Error(
56
+ alg.includes(FALLBACK_ALG)
57
+ ? `Client authentication method "${method}" requires at least one "${FALLBACK_ALG}" signing key with a "kid" property`
58
+ : // AS is not compliant with the ATproto OAuth spec.
59
+ `Authorization server requires "${method}" authentication method, but does not support "${FALLBACK_ALG}" algorithm.`,
60
+ )
61
+ }
62
+
63
+ if (method === 'none') {
64
+ return { method: 'none' }
65
+ }
66
+
67
+ throw new Error(
68
+ `The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.` +
69
+ (method === 'client_secret_basic'
70
+ ? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.'
71
+ : ` You set "${method}" which is not allowed.`),
72
+ )
73
+ }
74
+
75
+ export type ClientCredentialsFactory = () => Awaitable<{
76
+ headers?: Record<string, string>
77
+ payload?: OAuthClientCredentials
78
+ }>
79
+
80
+ /**
81
+ * @throws {AuthMethodUnsatisfiableError} if the authentication method is no
82
+ * long usable (either because the AS changed, of because the key is no longer
83
+ * available in the keyset).
84
+ */
85
+ export function createClientCredentialsFactory(
86
+ authMethod: ClientAuthMethod,
87
+ serverMetadata: OAuthAuthorizationServerMetadata,
88
+ clientMetadata: ClientMetadata,
89
+ runtime: Runtime,
90
+ keyset?: Keyset,
91
+ ): ClientCredentialsFactory {
92
+ // Ensure the AS still supports the auth method.
93
+ if (!supportedMethods(serverMetadata).includes(authMethod.method)) {
94
+ throw new AuthMethodUnsatisfiableError(
95
+ `Client authentication method "${authMethod.method}" no longer supported`,
96
+ )
97
+ }
98
+
99
+ if (authMethod.method === 'none') {
100
+ return () => ({
101
+ payload: {
102
+ client_id: clientMetadata.client_id,
103
+ },
104
+ })
105
+ }
106
+
107
+ if (authMethod.method === 'private_key_jwt') {
108
+ try {
109
+ // The client used to be a confidential client but no longer has a keyset.
110
+ if (!keyset) throw new Error('A keyset is required for private_key_jwt')
111
+
112
+ // @NOTE throws if no matching key can be found
113
+ const [key, alg] = keyset.findPrivateKey({
114
+ use: 'sig',
115
+ kid: authMethod.kid,
116
+ alg: supportedAlgs(serverMetadata),
117
+ })
118
+
119
+ // https://www.rfc-editor.org/rfc/rfc7523.html#section-3
120
+ return async () => ({
121
+ payload: {
122
+ client_id: clientMetadata.client_id,
123
+ client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
124
+ client_assertion: await key.createJwt(
125
+ { alg },
126
+ {
127
+ // > The JWT MUST contain an "iss" (issuer) claim that contains a
128
+ // > unique identifier for the entity that issued the JWT.
129
+ iss: clientMetadata.client_id,
130
+ // > For client authentication, the subject MUST be the
131
+ // > "client_id" of the OAuth client.
132
+ sub: clientMetadata.client_id,
133
+ // > The JWT MUST contain an "aud" (audience) claim containing a value
134
+ // > that identifies the authorization server as an intended audience.
135
+ // > The token endpoint URL of the authorization server MAY be used as a
136
+ // > value for an "aud" element to identify the authorization server as an
137
+ // > intended audience of the JWT.
138
+ aud: serverMetadata.issuer,
139
+ // > The JWT MAY contain a "jti" (JWT ID) claim that provides a
140
+ // > unique identifier for the token.
141
+ jti: await runtime.generateNonce(),
142
+ // > The JWT MAY contain an "iat" (issued at) claim that
143
+ // > identifies the time at which the JWT was issued.
144
+ iat: Math.floor(Date.now() / 1000),
145
+ // > The JWT MUST contain an "exp" (expiration time) claim that
146
+ // > limits the time window during which the JWT can be used.
147
+ exp: Math.floor(Date.now() / 1000) + 60, // 1 minute
148
+ },
149
+ ),
150
+ },
151
+ })
152
+ } catch (cause) {
153
+ throw new AuthMethodUnsatisfiableError('Failed to load private key', {
154
+ cause,
155
+ })
156
+ }
157
+ }
158
+
159
+ throw new AuthMethodUnsatisfiableError(
160
+ // @ts-expect-error
161
+ `Unsupported auth method ${authMethod.method}`,
162
+ )
163
+ }
164
+
165
+ function supportedMethods(serverMetadata: OAuthAuthorizationServerMetadata) {
166
+ return serverMetadata['token_endpoint_auth_methods_supported']
167
+ }
168
+
169
+ function supportedAlgs(serverMetadata: OAuthAuthorizationServerMetadata) {
170
+ return (
171
+ serverMetadata['token_endpoint_auth_signing_alg_values_supported'] ?? [
172
+ // @NOTE If not specified, assume that the server supports the ES256
173
+ // algorithm, as prescribed by the spec:
174
+ //
175
+ // > Clients and Authorization Servers currently must support the ES256
176
+ // > cryptographic system [for client authentication].
177
+ //
178
+ // https://atproto.com/specs/oauth#confidential-client-authentication
179
+ FALLBACK_ALG,
180
+ ]
181
+ )
182
+ }
@@ -26,12 +26,14 @@ import {
26
26
  import { IdentityResolver } from '@atproto-labs/identity-resolver'
27
27
  import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
28
28
  import { FALLBACK_ALG } from './constants.js'
29
+ import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
29
30
  import { TokenRevokedError } from './errors/token-revoked-error.js'
30
31
  import {
31
32
  AuthorizationServerMetadataCache,
32
33
  OAuthAuthorizationServerMetadataResolver,
33
34
  } from './oauth-authorization-server-metadata-resolver.js'
34
35
  import { OAuthCallbackError } from './oauth-callback-error.js'
36
+ import { negotiateClientAuthMethod } from './oauth-client-auth.js'
35
37
  import {
36
38
  OAuthProtectedResourceMetadataResolver,
37
39
  ProtectedResourceMetadataCache,
@@ -290,11 +292,17 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
290
292
  metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
291
293
  )
292
294
 
295
+ const authMethod = negotiateClientAuthMethod(
296
+ metadata,
297
+ this.clientMetadata,
298
+ this.keyset,
299
+ )
293
300
  const state = await this.runtime.generateNonce()
294
301
 
295
302
  await this.stateStore.set(state, {
296
303
  iss: metadata.issuer,
297
304
  dpopKey,
305
+ authMethod,
298
306
  verifier: pkce.verifier,
299
307
  appState: options?.state,
300
308
  })
@@ -327,7 +335,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
327
335
  }
328
336
 
329
337
  if (metadata.pushed_authorization_request_endpoint) {
330
- const server = await this.serverFactory.fromMetadata(metadata, dpopKey)
338
+ const server = await this.serverFactory.fromMetadata(
339
+ metadata,
340
+ authMethod,
341
+ dpopKey,
342
+ )
331
343
  const parResponse = await server.request(
332
344
  'pushed_authorization_request',
333
345
  parameters,
@@ -423,6 +435,8 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
423
435
 
424
436
  const server = await this.serverFactory.fromIssuer(
425
437
  stateData.iss,
438
+ // Using the literal 'legacy' if the authMethod is not defined (because stateData was created through an old version of this lib)
439
+ stateData.authMethod ?? 'legacy',
426
440
  stateData.dpopKey,
427
441
  )
428
442
 
@@ -455,6 +469,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
455
469
  try {
456
470
  await this.sessionGetter.setStored(tokenSet.sub, {
457
471
  dpopKey: stateData.dpopKey,
472
+ authMethod: server.authMethod,
458
473
  tokenSet,
459
474
  })
460
475
 
@@ -486,24 +501,45 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
486
501
  // sub arg is lightly typed for convenience of library user
487
502
  assertAtprotoDid(sub)
488
503
 
489
- const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
504
+ const {
505
+ dpopKey,
506
+ authMethod = 'legacy',
507
+ tokenSet,
508
+ } = await this.sessionGetter.get(sub, {
490
509
  noCache: refresh === true,
491
510
  allowStale: refresh === false,
492
511
  })
493
512
 
494
- const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, {
495
- noCache: refresh === true,
496
- allowStale: refresh === false,
497
- })
513
+ try {
514
+ const server = await this.serverFactory.fromIssuer(
515
+ tokenSet.iss,
516
+ authMethod,
517
+ dpopKey,
518
+ {
519
+ noCache: refresh === true,
520
+ allowStale: refresh === false,
521
+ },
522
+ )
523
+
524
+ return this.createSession(server, sub)
525
+ } catch (err) {
526
+ if (err instanceof AuthMethodUnsatisfiableError) {
527
+ await this.sessionGetter.delStored(sub, err)
528
+ }
498
529
 
499
- return this.createSession(server, sub)
530
+ throw err
531
+ }
500
532
  }
501
533
 
502
534
  async revoke(sub: string) {
503
535
  // sub arg is lightly typed for convenience of library user
504
536
  assertAtprotoDid(sub)
505
537
 
506
- const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
538
+ const {
539
+ dpopKey,
540
+ authMethod = 'legacy',
541
+ tokenSet,
542
+ } = await this.sessionGetter.get(sub, {
507
543
  allowStale: true,
508
544
  })
509
545
 
@@ -511,7 +547,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
511
547
  // the tokens to be deleted even if it was not possible to fetch the issuer
512
548
  // data.
513
549
  try {
514
- const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
550
+ const server = await this.serverFactory.fromIssuer(
551
+ tokenSet.iss,
552
+ authMethod,
553
+ dpopKey,
554
+ )
515
555
  await server.revoke(tokenSet.access_token)
516
556
  } finally {
517
557
  await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
@@ -1,10 +1,8 @@
1
1
  import { AtprotoDid } from '@atproto/did'
2
2
  import { Key, Keyset } from '@atproto/jwk'
3
3
  import {
4
- CLIENT_ASSERTION_TYPE_JWT_BEARER,
5
4
  OAuthAuthorizationRequestPar,
6
5
  OAuthAuthorizationServerMetadata,
7
- OAuthClientCredentials,
8
6
  OAuthEndpointName,
9
7
  OAuthParResponse,
10
8
  OAuthTokenRequest,
@@ -17,9 +15,13 @@ import {
17
15
  AtprotoTokenResponse,
18
16
  atprotoTokenResponseSchema,
19
17
  } from './atproto-token-response.js'
20
- import { FALLBACK_ALG } from './constants.js'
21
18
  import { TokenRefreshError } from './errors/token-refresh-error.js'
22
19
  import { dpopFetchWrapper } from './fetch-dpop.js'
20
+ import {
21
+ ClientAuthMethod,
22
+ ClientCredentialsFactory,
23
+ createClientCredentialsFactory,
24
+ } from './oauth-client-auth.js'
23
25
  import { OAuthResolver } from './oauth-resolver.js'
24
26
  import { OAuthResponseError } from './oauth-response-error.js'
25
27
  import { Runtime } from './runtime.js'
@@ -43,8 +45,13 @@ export type DpopNonceCache = SimpleStore<string, string>
43
45
 
44
46
  export class OAuthServerAgent {
45
47
  protected dpopFetch: Fetch<unknown>
48
+ protected clientCredentialsFactory: ClientCredentialsFactory
46
49
 
50
+ /**
51
+ * @throws see {@link createClientCredentialsFactory}
52
+ */
47
53
  constructor(
54
+ readonly authMethod: ClientAuthMethod,
48
55
  readonly dpopKey: Key,
49
56
  readonly serverMetadata: OAuthAuthorizationServerMetadata,
50
57
  readonly clientMetadata: ClientMetadata,
@@ -54,6 +61,14 @@ export class OAuthServerAgent {
54
61
  readonly keyset?: Keyset,
55
62
  fetch?: Fetch,
56
63
  ) {
64
+ this.clientCredentialsFactory = createClientCredentialsFactory(
65
+ authMethod,
66
+ serverMetadata,
67
+ clientMetadata,
68
+ runtime,
69
+ keyset,
70
+ )
71
+
57
72
  this.dpopFetch = dpopFetchWrapper<void>({
58
73
  fetch: bindFetch(fetch),
59
74
  key: dpopKey,
@@ -204,7 +219,7 @@ export class OAuthServerAgent {
204
219
  const url = this.serverMetadata[`${endpoint}_endpoint`]
205
220
  if (!url) throw new Error(`No ${endpoint} endpoint available`)
206
221
 
207
- const auth = await this.buildClientAuth(endpoint)
222
+ const auth = await this.clientCredentialsFactory()
208
223
 
209
224
  // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-3.2.2
210
225
  // https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
@@ -232,73 +247,6 @@ export class OAuthServerAgent {
232
247
  throw new OAuthResponseError(response, json)
233
248
  }
234
249
  }
235
-
236
- async buildClientAuth(endpoint: OAuthEndpointName): Promise<{
237
- headers?: Record<string, string>
238
- payload: OAuthClientCredentials
239
- }> {
240
- const methodSupported =
241
- this.serverMetadata[`token_endpoint_auth_methods_supported`]
242
-
243
- const method = this.clientMetadata[`token_endpoint_auth_method`]
244
-
245
- if (
246
- method === 'private_key_jwt' ||
247
- (this.keyset &&
248
- !method &&
249
- (methodSupported?.includes('private_key_jwt') ?? false))
250
- ) {
251
- if (!this.keyset) throw new Error('No keyset available')
252
-
253
- try {
254
- const alg =
255
- this.serverMetadata[
256
- `token_endpoint_auth_signing_alg_values_supported`
257
- ] ?? FALLBACK_ALG
258
-
259
- // If jwks is defined, make sure to only sign using a key that exists in
260
- // the jwks. If jwks_uri is defined, we can't be sure that the key we're
261
- // looking for is in there so we will just assume it is.
262
- const kid = this.clientMetadata.jwks?.keys
263
- .map(({ kid }) => kid)
264
- .filter((v): v is string => typeof v === 'string')
265
-
266
- return {
267
- payload: {
268
- client_id: this.clientMetadata.client_id,
269
- client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
270
- client_assertion: await this.keyset.createJwt(
271
- { alg, kid },
272
- {
273
- iss: this.clientMetadata.client_id,
274
- sub: this.clientMetadata.client_id,
275
- aud: this.serverMetadata.issuer,
276
- jti: await this.runtime.generateNonce(),
277
- iat: Math.floor(Date.now() / 1000),
278
- },
279
- ),
280
- },
281
- }
282
- } catch (err) {
283
- if (method === 'private_key_jwt') throw err
284
-
285
- // Else try next method
286
- }
287
- }
288
-
289
- if (
290
- method === 'none' ||
291
- (!method && (methodSupported?.includes('none') ?? true))
292
- ) {
293
- return {
294
- payload: {
295
- client_id: this.clientMetadata.client_id,
296
- },
297
- }
298
- }
299
-
300
- throw new Error(`Unsupported ${endpoint} authentication method`)
301
- }
302
250
  }
303
251
 
304
252
  function wwwFormUrlEncode(payload: Record<string, undefined | unknown>) {
@@ -2,6 +2,10 @@ import { Key, Keyset } from '@atproto/jwk'
2
2
  import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'
3
3
  import { Fetch } from '@atproto-labs/fetch'
4
4
  import { GetCachedOptions } from './oauth-authorization-server-metadata-resolver.js'
5
+ import {
6
+ ClientAuthMethod,
7
+ negotiateClientAuthMethod,
8
+ } from './oauth-client-auth.js'
5
9
  import { OAuthResolver } from './oauth-resolver.js'
6
10
  import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
7
11
  import { Runtime } from './runtime.js'
@@ -17,19 +21,50 @@ export class OAuthServerFactory {
17
21
  readonly dpopNonceCache: DpopNonceCache,
18
22
  ) {}
19
23
 
20
- async fromIssuer(issuer: string, dpopKey: Key, options?: GetCachedOptions) {
24
+ /**
25
+ * @param authMethod `undefined` means that we are restoring a session that
26
+ * was created before we started storing the `authMethod` in the session. In
27
+ * that case, we will use the first key from the keyset.
28
+ *
29
+ * Support for this might be removed in the future.
30
+ *
31
+ * @throws see {@link OAuthServerFactory.fromMetadata}
32
+ */
33
+ async fromIssuer(
34
+ issuer: string,
35
+ authMethod: 'legacy' | ClientAuthMethod,
36
+ dpopKey: Key,
37
+ options?: GetCachedOptions,
38
+ ) {
21
39
  const serverMetadata = await this.resolver.getAuthorizationServerMetadata(
22
40
  issuer,
23
41
  options,
24
42
  )
25
- return this.fromMetadata(serverMetadata, dpopKey)
43
+
44
+ if (authMethod === 'legacy') {
45
+ // @NOTE Because we were previously not storing the authMethod in the
46
+ // session data, we provide a backwards compatible implementation by
47
+ // computing it here.
48
+ authMethod = negotiateClientAuthMethod(
49
+ serverMetadata,
50
+ this.clientMetadata,
51
+ this.keyset,
52
+ )
53
+ }
54
+
55
+ return this.fromMetadata(serverMetadata, authMethod, dpopKey)
26
56
  }
27
57
 
58
+ /**
59
+ * @throws see {@link OAuthServerAgent}
60
+ */
28
61
  async fromMetadata(
29
62
  serverMetadata: OAuthAuthorizationServerMetadata,
63
+ authMethod: ClientAuthMethod,
30
64
  dpopKey: Key,
31
65
  ) {
32
66
  return new OAuthServerAgent(
67
+ authMethod,
33
68
  dpopKey,
34
69
  serverMetadata,
35
70
  this.clientMetadata,
@@ -5,9 +5,11 @@ import {
5
5
  GetCachedOptions,
6
6
  SimpleStore,
7
7
  } from '@atproto-labs/simple-store'
8
+ import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
8
9
  import { TokenInvalidError } from './errors/token-invalid-error.js'
9
10
  import { TokenRefreshError } from './errors/token-refresh-error.js'
10
11
  import { TokenRevokedError } from './errors/token-revoked-error.js'
12
+ import { ClientAuthMethod } from './oauth-client-auth.js'
11
13
  import { OAuthResponseError } from './oauth-response-error.js'
12
14
  import { TokenSet } from './oauth-server-agent.js'
13
15
  import { OAuthServerFactory } from './oauth-server-factory.js'
@@ -16,6 +18,10 @@ import { CustomEventTarget, combineSignals, timeoutSignal } from './util.js'
16
18
 
17
19
  export type Session = {
18
20
  dpopKey: Key
21
+ /**
22
+ * Previous implementation of this lib did not define an `authMethod`
23
+ */
24
+ authMethod?: ClientAuthMethod
19
25
  tokenSet: TokenSet
20
26
  }
21
27
 
@@ -51,7 +57,7 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
51
57
  private readonly runtime: Runtime,
52
58
  ) {
53
59
  super(
54
- async (sub, options, storedSession): Promise<Session> => {
60
+ async (sub, options, storedSession) => {
55
61
  // There needs to be a previous session to be able to refresh. If
56
62
  // storedSession is undefined, it means that the store does not contain
57
63
  // a session for the given sub.
@@ -73,7 +79,7 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
73
79
  // concurrent access (which, normally, should not happen if a proper
74
80
  // runtime lock was provided).
75
81
 
76
- const { dpopKey, tokenSet } = storedSession
82
+ const { dpopKey, authMethod = 'legacy', tokenSet } = storedSession
77
83
 
78
84
  if (sub !== tokenSet.sub) {
79
85
  // Fool-proofing (e.g. against invalid session storage)
@@ -94,7 +100,11 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
94
100
  // always possible. If no lock implementation is provided, we will use
95
101
  // the store to check if a concurrent refresh occurred.
96
102
 
97
- const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
103
+ const server = await serverFactory.fromIssuer(
104
+ tokenSet.iss,
105
+ authMethod,
106
+ dpopKey,
107
+ )
98
108
 
99
109
  // Because refresh tokens can only be used once, we must not use the
100
110
  // "signal" to abort the refresh, or throw any abort error beyond this
@@ -111,7 +121,11 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
111
121
  throw new TokenRefreshError(sub, 'Token set sub mismatch')
112
122
  }
113
123
 
114
- return { dpopKey, tokenSet: newTokenSet }
124
+ return {
125
+ dpopKey,
126
+ tokenSet: newTokenSet,
127
+ authMethod: server.authMethod,
128
+ }
115
129
  } catch (cause) {
116
130
  // If the refresh token is invalid, let's try to recover from
117
131
  // concurrency issues, or make sure the session is deleted by throwing
@@ -173,17 +187,36 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
173
187
  30e3 * Math.random()
174
188
  )
175
189
  },
176
- onStoreError: async (err, sub, { tokenSet, dpopKey }) => {
177
- // If the token data cannot be stored, let's revoke it
178
- const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
179
- await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
190
+ onStoreError: async (
191
+ err,
192
+ sub,
193
+ { tokenSet, dpopKey, authMethod = 'legacy' as const },
194
+ ) => {
195
+ if (!(err instanceof AuthMethodUnsatisfiableError)) {
196
+ // If the error was an AuthMethodUnsatisfiableError, there is no
197
+ // point in trying to call `fromIssuer`.
198
+ try {
199
+ // If the token data cannot be stored, let's revoke it
200
+ const server = await serverFactory.fromIssuer(
201
+ tokenSet.iss,
202
+ authMethod,
203
+ dpopKey,
204
+ )
205
+ await server.revoke(
206
+ tokenSet.refresh_token ?? tokenSet.access_token,
207
+ )
208
+ } catch {
209
+ // Let the original error propagate
210
+ }
211
+ }
212
+
180
213
  throw err
181
214
  },
182
215
  deleteOnError: async (err) =>
183
- // Optimization: More likely to happen first
184
216
  err instanceof TokenRefreshError ||
185
217
  err instanceof TokenRevokedError ||
186
- err instanceof TokenInvalidError,
218
+ err instanceof TokenInvalidError ||
219
+ err instanceof AuthMethodUnsatisfiableError,
187
220
  },
188
221
  )
189
222
  }
@@ -1,9 +1,12 @@
1
1
  import { Key } from '@atproto/jwk'
2
2
  import { SimpleStore } from '@atproto-labs/simple-store'
3
+ import { ClientAuthMethod } from './oauth-client-auth.js'
3
4
 
4
5
  export type InternalStateData = {
5
6
  iss: string
6
7
  dpopKey: Key
8
+ /** @note optional for legacy reasons */
9
+ authMethod?: ClientAuthMethod
7
10
  verifier?: string
8
11
  appState?: string
9
12
  }