@atproto/oauth-client 0.3.21 → 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 (52) hide show
  1. package/CHANGELOG.md +25 -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/fetch-dpop.d.ts +1 -2
  7. package/dist/fetch-dpop.d.ts.map +1 -1
  8. package/dist/fetch-dpop.js +4 -5
  9. package/dist/fetch-dpop.js.map +1 -1
  10. package/dist/oauth-client-auth.d.ts +23 -0
  11. package/dist/oauth-client-auth.d.ts.map +1 -0
  12. package/dist/oauth-client-auth.js +131 -0
  13. package/dist/oauth-client-auth.js.map +1 -0
  14. package/dist/oauth-client.d.ts +4 -4
  15. package/dist/oauth-client.d.ts.map +1 -1
  16. package/dist/oauth-client.js +26 -13
  17. package/dist/oauth-client.js.map +1 -1
  18. package/dist/oauth-resolver.d.ts +1 -1
  19. package/dist/oauth-server-agent.d.ts +8 -6
  20. package/dist/oauth-server-agent.d.ts.map +1 -1
  21. package/dist/oauth-server-agent.js +19 -51
  22. package/dist/oauth-server-agent.js.map +1 -1
  23. package/dist/oauth-server-factory.d.ts +15 -2
  24. package/dist/oauth-server-factory.d.ts.map +1 -1
  25. package/dist/oauth-server-factory.js +23 -4
  26. package/dist/oauth-server-factory.js.map +1 -1
  27. package/dist/oauth-session.d.ts.map +1 -1
  28. package/dist/oauth-session.js +0 -1
  29. package/dist/oauth-session.js.map +1 -1
  30. package/dist/session-getter.d.ts +5 -0
  31. package/dist/session-getter.d.ts.map +1 -1
  32. package/dist/session-getter.js +24 -11
  33. package/dist/session-getter.js.map +1 -1
  34. package/dist/state-store.d.ts +3 -0
  35. package/dist/state-store.d.ts.map +1 -1
  36. package/dist/types.d.ts +8 -8
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/validate-client-metadata.d.ts.map +1 -1
  39. package/dist/validate-client-metadata.js +32 -26
  40. package/dist/validate-client-metadata.js.map +1 -1
  41. package/package.json +4 -4
  42. package/src/errors/auth-method-unsatisfiable-error.ts +1 -0
  43. package/src/fetch-dpop.ts +2 -6
  44. package/src/oauth-client-auth.ts +182 -0
  45. package/src/oauth-client.ts +50 -12
  46. package/src/oauth-server-agent.ts +19 -72
  47. package/src/oauth-server-factory.ts +37 -2
  48. package/src/oauth-session.ts +0 -1
  49. package/src/session-getter.ts +43 -10
  50. package/src/state-store.ts +3 -0
  51. package/src/validate-client-metadata.ts +40 -27
  52. package/tsconfig.build.tsbuildinfo +1 -1
@@ -2,23 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateClientMetadata = validateClientMetadata;
4
4
  const oauth_types_1 = require("@atproto/oauth-types");
5
+ const constants_js_1 = require("./constants.js");
5
6
  const types_js_1 = require("./types.js");
6
- const TOKEN_ENDPOINT_AUTH_METHOD = `token_endpoint_auth_method`;
7
- const TOKEN_ENDPOINT_AUTH_SIGNING_ALG = `token_endpoint_auth_signing_alg`;
8
7
  function validateClientMetadata(input, keyset) {
9
- if (input.jwks) {
10
- if (!keyset) {
11
- throw new TypeError(`Keyset must not be provided when jwks is provided`);
12
- }
13
- for (const key of input.jwks.keys) {
14
- if (!key.kid) {
15
- throw new TypeError(`Key must have a "kid" property`);
16
- }
17
- else if (!keyset.has(key.kid)) {
18
- throw new TypeError(`Key with kid "${key.kid}" not found in keyset`);
19
- }
20
- }
21
- }
22
8
  // Allow to pass a keyset and omit the jwks/jwks_uri properties
23
9
  if (!input.jwks && !input.jwks_uri && keyset?.size) {
24
10
  input = { ...input, jwks: keyset.toJSON() };
@@ -41,25 +27,45 @@ function validateClientMetadata(input, keyset) {
41
27
  if (!metadata.grant_types.includes('authorization_code')) {
42
28
  throw new TypeError(`"grant_types" must include "authorization_code"`);
43
29
  }
44
- const method = metadata[TOKEN_ENDPOINT_AUTH_METHOD];
30
+ const method = metadata.token_endpoint_auth_method;
31
+ const methodAlg = metadata.token_endpoint_auth_signing_alg;
45
32
  switch (method) {
46
- case undefined:
47
- throw new TypeError(`${TOKEN_ENDPOINT_AUTH_METHOD} must be provided`);
48
33
  case 'none':
49
- if (metadata[TOKEN_ENDPOINT_AUTH_SIGNING_ALG]) {
50
- throw new TypeError(`${TOKEN_ENDPOINT_AUTH_SIGNING_ALG} must not be provided when ${TOKEN_ENDPOINT_AUTH_METHOD} is "${method}"`);
34
+ if (methodAlg) {
35
+ throw new TypeError(`"token_endpoint_auth_signing_alg" must not be provided when "token_endpoint_auth_method" is "${method}"`);
51
36
  }
52
37
  break;
53
- case 'private_key_jwt':
54
- if (!keyset?.size) {
55
- throw new TypeError(`A non-empty keyset must be provided when ${TOKEN_ENDPOINT_AUTH_METHOD} is "${method}"`);
38
+ case 'private_key_jwt': {
39
+ if (!methodAlg) {
40
+ throw new TypeError(`"token_endpoint_auth_signing_alg" must be provided when "token_endpoint_auth_method" is "${method}"`);
56
41
  }
57
- if (!metadata[TOKEN_ENDPOINT_AUTH_SIGNING_ALG]) {
58
- throw new TypeError(`${TOKEN_ENDPOINT_AUTH_SIGNING_ALG} must be provided when ${TOKEN_ENDPOINT_AUTH_METHOD} is "${method}"`);
42
+ const signingKeys = keyset
43
+ ? Array.from(keyset.list({ use: 'sig' })).filter((key) => key.isPrivate && key.kid)
44
+ : null;
45
+ if (!signingKeys?.some((key) => key.algorithms.includes(constants_js_1.FALLBACK_ALG))) {
46
+ throw new TypeError(`Client authentication method "${method}" requires at least one "${constants_js_1.FALLBACK_ALG}" signing key with a "kid" property`);
47
+ }
48
+ if (metadata.jwks) {
49
+ // Ensure that all the signing keys that could end-up being used are
50
+ // advertised in the JWKS.
51
+ for (const key of signingKeys) {
52
+ if (!metadata.jwks.keys.some((k) => k.kid === key.kid)) {
53
+ throw new TypeError(`Key with kid "${key.kid}" not found in jwks`);
54
+ }
55
+ }
56
+ }
57
+ else if (metadata.jwks_uri) {
58
+ // @NOTE we only ensure that all the signing keys are referenced in JWKS
59
+ // when it is available (see previous "if") as we don't want to download
60
+ // that file here (for efficiency reasons).
61
+ }
62
+ else {
63
+ throw new TypeError(`Client authentication method "${method}" requires a JWKS`);
59
64
  }
60
65
  break;
66
+ }
61
67
  default:
62
- throw new TypeError(`Invalid "token_endpoint_auth_method" value: ${method}`);
68
+ throw new TypeError(`Unsupported "token_endpoint_auth_method" value: ${method}`);
63
69
  }
64
70
  return metadata;
65
71
  }
@@ -1 +1 @@
1
- {"version":3,"file":"validate-client-metadata.js","sourceRoot":"","sources":["../src/validate-client-metadata.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"validate-client-metadata.js","sourceRoot":"","sources":["../src/validate-client-metadata.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/oauth-client",
3
- "version": "0.3.21",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).",
6
6
  "keywords": [
@@ -30,12 +30,12 @@
30
30
  "@atproto-labs/did-resolver": "0.1.13",
31
31
  "@atproto-labs/fetch": "0.2.3",
32
32
  "@atproto-labs/handle-resolver": "0.1.8",
33
- "@atproto-labs/identity-resolver": "0.1.17",
33
+ "@atproto-labs/identity-resolver": "0.1.18",
34
34
  "@atproto-labs/simple-store": "0.2.0",
35
35
  "@atproto-labs/simple-store-memory": "0.1.3",
36
36
  "@atproto/did": "0.1.5",
37
- "@atproto/jwk": "0.2.0",
38
- "@atproto/oauth-types": "0.2.8",
37
+ "@atproto/jwk": "0.3.0",
38
+ "@atproto/oauth-types": "0.3.0",
39
39
  "@atproto/xrpc": "0.7.0"
40
40
  },
41
41
  "devDependencies": {
@@ -0,0 +1 @@
1
+ export class AuthMethodUnsatisfiableError extends Error {}
package/src/fetch-dpop.ts CHANGED
@@ -12,7 +12,6 @@ const ReadableStream = globalThis.ReadableStream as
12
12
 
13
13
  export type DpopFetchWrapperOptions<C = FetchContext> = {
14
14
  key: Key
15
- iss: string
16
15
  nonces: SimpleStore<string, string>
17
16
  supportedAlgs?: string[]
18
17
  sha256?: (input: string) => Promise<string>
@@ -30,7 +29,6 @@ export type DpopFetchWrapperOptions<C = FetchContext> = {
30
29
 
31
30
  export function dpopFetchWrapper<C = FetchContext>({
32
31
  key,
33
- iss,
34
32
  // @TODO we should provide a default based on specs
35
33
  supportedAlgs,
36
34
  nonces,
@@ -70,7 +68,7 @@ export function dpopFetchWrapper<C = FetchContext>({
70
68
  // Ignore get errors, we will just not send a nonce
71
69
  }
72
70
 
73
- const initProof = await buildProof(key, alg, iss, htm, htu, initNonce, ath)
71
+ const initProof = await buildProof(key, alg, htm, htu, initNonce, ath)
74
72
  request.headers.set('DPoP', initProof)
75
73
 
76
74
  const initResponse = await fetch.call(this, request)
@@ -118,7 +116,7 @@ export function dpopFetchWrapper<C = FetchContext>({
118
116
  // The initial response body must be consumed (see cancelBody's doc).
119
117
  await cancelBody(initResponse, 'log')
120
118
 
121
- const nextProof = await buildProof(key, alg, iss, htm, htu, nextNonce, ath)
119
+ const nextProof = await buildProof(key, alg, htm, htu, nextNonce, ath)
122
120
  const nextRequest = new Request(input, init)
123
121
  nextRequest.headers.set('DPoP', nextProof)
124
122
 
@@ -163,7 +161,6 @@ function buildHtu(url: string): string {
163
161
  async function buildProof(
164
162
  key: Key,
165
163
  alg: string,
166
- iss: string,
167
164
  htm: string,
168
165
  htu: string,
169
166
  nonce?: string,
@@ -184,7 +181,6 @@ async function buildProof(
184
181
  jwk,
185
182
  },
186
183
  {
187
- iss,
188
184
  iat: now,
189
185
  // Any collision will cause the request to be rejected by the server. no biggie.
190
186
  jti: Math.random().toString(36).slice(2),
@@ -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
  })
@@ -307,9 +315,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
307
315
  code_challenge: pkce.challenge,
308
316
  code_challenge_method: pkce.method,
309
317
  state,
310
- login_hint: identity
311
- ? input // If input is a handle or a DID, use it as a login_hint
312
- : undefined,
318
+ login_hint: identity?.handle ?? identity?.did,
313
319
  response_mode: this.responseMode,
314
320
  response_type: 'code' as const,
315
321
  scope: options?.scope ?? this.clientMetadata.scope,
@@ -329,7 +335,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
329
335
  }
330
336
 
331
337
  if (metadata.pushed_authorization_request_endpoint) {
332
- const server = await this.serverFactory.fromMetadata(metadata, dpopKey)
338
+ const server = await this.serverFactory.fromMetadata(
339
+ metadata,
340
+ authMethod,
341
+ dpopKey,
342
+ )
333
343
  const parResponse = await server.request(
334
344
  'pushed_authorization_request',
335
345
  parameters,
@@ -425,6 +435,8 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
425
435
 
426
436
  const server = await this.serverFactory.fromIssuer(
427
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',
428
440
  stateData.dpopKey,
429
441
  )
430
442
 
@@ -457,6 +469,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
457
469
  try {
458
470
  await this.sessionGetter.setStored(tokenSet.sub, {
459
471
  dpopKey: stateData.dpopKey,
472
+ authMethod: server.authMethod,
460
473
  tokenSet,
461
474
  })
462
475
 
@@ -488,24 +501,45 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
488
501
  // sub arg is lightly typed for convenience of library user
489
502
  assertAtprotoDid(sub)
490
503
 
491
- const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
504
+ const {
505
+ dpopKey,
506
+ authMethod = 'legacy',
507
+ tokenSet,
508
+ } = await this.sessionGetter.get(sub, {
492
509
  noCache: refresh === true,
493
510
  allowStale: refresh === false,
494
511
  })
495
512
 
496
- const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, {
497
- noCache: refresh === true,
498
- allowStale: refresh === false,
499
- })
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
+ }
500
529
 
501
- return this.createSession(server, sub)
530
+ throw err
531
+ }
502
532
  }
503
533
 
504
534
  async revoke(sub: string) {
505
535
  // sub arg is lightly typed for convenience of library user
506
536
  assertAtprotoDid(sub)
507
537
 
508
- const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
538
+ const {
539
+ dpopKey,
540
+ authMethod = 'legacy',
541
+ tokenSet,
542
+ } = await this.sessionGetter.get(sub, {
509
543
  allowStale: true,
510
544
  })
511
545
 
@@ -513,7 +547,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
513
547
  // the tokens to be deleted even if it was not possible to fetch the issuer
514
548
  // data.
515
549
  try {
516
- const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
550
+ const server = await this.serverFactory.fromIssuer(
551
+ tokenSet.iss,
552
+ authMethod,
553
+ dpopKey,
554
+ )
517
555
  await server.revoke(tokenSet.access_token)
518
556
  } finally {
519
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,9 +61,16 @@ 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
- iss: clientMetadata.client_id,
60
74
  key: dpopKey,
61
75
  supportedAlgs: serverMetadata.dpop_signing_alg_values_supported,
62
76
  sha256: async (v) => runtime.sha256(v),
@@ -205,7 +219,7 @@ export class OAuthServerAgent {
205
219
  const url = this.serverMetadata[`${endpoint}_endpoint`]
206
220
  if (!url) throw new Error(`No ${endpoint} endpoint available`)
207
221
 
208
- const auth = await this.buildClientAuth(endpoint)
222
+ const auth = await this.clientCredentialsFactory()
209
223
 
210
224
  // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-3.2.2
211
225
  // https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
@@ -233,73 +247,6 @@ export class OAuthServerAgent {
233
247
  throw new OAuthResponseError(response, json)
234
248
  }
235
249
  }
236
-
237
- async buildClientAuth(endpoint: OAuthEndpointName): Promise<{
238
- headers?: Record<string, string>
239
- payload: OAuthClientCredentials
240
- }> {
241
- const methodSupported =
242
- this.serverMetadata[`token_endpoint_auth_methods_supported`]
243
-
244
- const method = this.clientMetadata[`token_endpoint_auth_method`]
245
-
246
- if (
247
- method === 'private_key_jwt' ||
248
- (this.keyset &&
249
- !method &&
250
- (methodSupported?.includes('private_key_jwt') ?? false))
251
- ) {
252
- if (!this.keyset) throw new Error('No keyset available')
253
-
254
- try {
255
- const alg =
256
- this.serverMetadata[
257
- `token_endpoint_auth_signing_alg_values_supported`
258
- ] ?? FALLBACK_ALG
259
-
260
- // If jwks is defined, make sure to only sign using a key that exists in
261
- // the jwks. If jwks_uri is defined, we can't be sure that the key we're
262
- // looking for is in there so we will just assume it is.
263
- const kid = this.clientMetadata.jwks?.keys
264
- .map(({ kid }) => kid)
265
- .filter((v): v is string => typeof v === 'string')
266
-
267
- return {
268
- payload: {
269
- client_id: this.clientMetadata.client_id,
270
- client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
271
- client_assertion: await this.keyset.createJwt(
272
- { alg, kid },
273
- {
274
- iss: this.clientMetadata.client_id,
275
- sub: this.clientMetadata.client_id,
276
- aud: this.serverMetadata.issuer,
277
- jti: await this.runtime.generateNonce(),
278
- iat: Math.floor(Date.now() / 1000),
279
- },
280
- ),
281
- },
282
- }
283
- } catch (err) {
284
- if (method === 'private_key_jwt') throw err
285
-
286
- // Else try next method
287
- }
288
- }
289
-
290
- if (
291
- method === 'none' ||
292
- (!method && (methodSupported?.includes('none') ?? true))
293
- ) {
294
- return {
295
- payload: {
296
- client_id: this.clientMetadata.client_id,
297
- },
298
- }
299
- }
300
-
301
- throw new Error(`Unsupported ${endpoint} authentication method`)
302
- }
303
250
  }
304
251
 
305
252
  function wwwFormUrlEncode(payload: Record<string, undefined | unknown>) {