@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,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,
@@ -32,7 +32,6 @@ export class OAuthSession {
32
32
  ) {
33
33
  this.dpopFetch = dpopFetchWrapper<void>({
34
34
  fetch: bindFetch(fetch),
35
- iss: server.clientMetadata.client_id,
36
35
  key: server.dpopKey,
37
36
  supportedAlgs: server.serverMetadata.dpop_signing_alg_values_supported,
38
37
  sha256: async (v) => server.runtime.sha256(v),
@@ -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
  }
@@ -4,28 +4,13 @@ import {
4
4
  assertOAuthDiscoverableClientId,
5
5
  assertOAuthLoopbackClientId,
6
6
  } from '@atproto/oauth-types'
7
+ import { FALLBACK_ALG } from './constants.js'
7
8
  import { ClientMetadata, clientMetadataSchema } from './types.js'
8
9
 
9
- const TOKEN_ENDPOINT_AUTH_METHOD = `token_endpoint_auth_method`
10
- const TOKEN_ENDPOINT_AUTH_SIGNING_ALG = `token_endpoint_auth_signing_alg`
11
-
12
10
  export function validateClientMetadata(
13
11
  input: OAuthClientMetadataInput,
14
12
  keyset?: Keyset,
15
13
  ): ClientMetadata {
16
- if (input.jwks) {
17
- if (!keyset) {
18
- throw new TypeError(`Keyset must not be provided when jwks is provided`)
19
- }
20
- for (const key of input.jwks.keys) {
21
- if (!key.kid) {
22
- throw new TypeError(`Key must have a "kid" property`)
23
- } else if (!keyset.has(key.kid)) {
24
- throw new TypeError(`Key with kid "${key.kid}" not found in keyset`)
25
- }
26
- }
27
- }
28
-
29
14
  // Allow to pass a keyset and omit the jwks/jwks_uri properties
30
15
  if (!input.jwks && !input.jwks_uri && keyset?.size) {
31
16
  input = { ...input, jwks: keyset.toJSON() }
@@ -53,32 +38,60 @@ export function validateClientMetadata(
53
38
  throw new TypeError(`"grant_types" must include "authorization_code"`)
54
39
  }
55
40
 
56
- const method = metadata[TOKEN_ENDPOINT_AUTH_METHOD]
41
+ const method = metadata.token_endpoint_auth_method
42
+ const methodAlg = metadata.token_endpoint_auth_signing_alg
57
43
  switch (method) {
58
- case undefined:
59
- throw new TypeError(`${TOKEN_ENDPOINT_AUTH_METHOD} must be provided`)
60
44
  case 'none':
61
- if (metadata[TOKEN_ENDPOINT_AUTH_SIGNING_ALG]) {
45
+ if (methodAlg) {
62
46
  throw new TypeError(
63
- `${TOKEN_ENDPOINT_AUTH_SIGNING_ALG} must not be provided when ${TOKEN_ENDPOINT_AUTH_METHOD} is "${method}"`,
47
+ `"token_endpoint_auth_signing_alg" must not be provided when "token_endpoint_auth_method" is "${method}"`,
64
48
  )
65
49
  }
66
50
  break
67
- case 'private_key_jwt':
68
- if (!keyset?.size) {
51
+
52
+ case 'private_key_jwt': {
53
+ if (!methodAlg) {
54
+ throw new TypeError(
55
+ `"token_endpoint_auth_signing_alg" must be provided when "token_endpoint_auth_method" is "${method}"`,
56
+ )
57
+ }
58
+
59
+ const signingKeys = keyset
60
+ ? Array.from(keyset.list({ use: 'sig' })).filter(
61
+ (key) => key.isPrivate && key.kid,
62
+ )
63
+ : null
64
+
65
+ if (!signingKeys?.some((key) => key.algorithms.includes(FALLBACK_ALG))) {
69
66
  throw new TypeError(
70
- `A non-empty keyset must be provided when ${TOKEN_ENDPOINT_AUTH_METHOD} is "${method}"`,
67
+ `Client authentication method "${method}" requires at least one "${FALLBACK_ALG}" signing key with a "kid" property`,
71
68
  )
72
69
  }
73
- if (!metadata[TOKEN_ENDPOINT_AUTH_SIGNING_ALG]) {
70
+
71
+ if (metadata.jwks) {
72
+ // Ensure that all the signing keys that could end-up being used are
73
+ // advertised in the JWKS.
74
+ for (const key of signingKeys) {
75
+ if (!metadata.jwks.keys.some((k) => k.kid === key.kid)) {
76
+ throw new TypeError(`Key with kid "${key.kid}" not found in jwks`)
77
+ }
78
+ }
79
+ } else if (metadata.jwks_uri) {
80
+ // @NOTE we only ensure that all the signing keys are referenced in JWKS
81
+ // when it is available (see previous "if") as we don't want to download
82
+ // that file here (for efficiency reasons).
83
+ } else {
74
84
  throw new TypeError(
75
- `${TOKEN_ENDPOINT_AUTH_SIGNING_ALG} must be provided when ${TOKEN_ENDPOINT_AUTH_METHOD} is "${method}"`,
85
+ `Client authentication method "${method}" requires a JWKS`,
76
86
  )
77
87
  }
88
+
78
89
  break
90
+ }
91
+
79
92
  default:
80
93
  throw new TypeError(
81
- `Invalid "token_endpoint_auth_method" value: ${method}`,
94
+ `Unsupported "token_endpoint_auth_method" value: ${method}`,
82
95
  )
83
96
  }
84
97
 
@@ -1 +1 @@
1
- {"root":["./src/atproto-token-response.ts","./src/constants.ts","./src/fetch-dpop.ts","./src/index.ts","./src/lock.ts","./src/oauth-authorization-server-metadata-resolver.ts","./src/oauth-callback-error.ts","./src/oauth-client.ts","./src/oauth-protected-resource-metadata-resolver.ts","./src/oauth-resolver-error.ts","./src/oauth-resolver.ts","./src/oauth-response-error.ts","./src/oauth-server-agent.ts","./src/oauth-server-factory.ts","./src/oauth-session.ts","./src/runtime-implementation.ts","./src/runtime.ts","./src/session-getter.ts","./src/state-store.ts","./src/types.ts","./src/util.ts","./src/validate-client-metadata.ts","./src/errors/token-invalid-error.ts","./src/errors/token-refresh-error.ts","./src/errors/token-revoked-error.ts"],"version":"5.8.2"}
1
+ {"root":["./src/atproto-token-response.ts","./src/constants.ts","./src/fetch-dpop.ts","./src/index.ts","./src/lock.ts","./src/oauth-authorization-server-metadata-resolver.ts","./src/oauth-callback-error.ts","./src/oauth-client-auth.ts","./src/oauth-client.ts","./src/oauth-protected-resource-metadata-resolver.ts","./src/oauth-resolver-error.ts","./src/oauth-resolver.ts","./src/oauth-response-error.ts","./src/oauth-server-agent.ts","./src/oauth-server-factory.ts","./src/oauth-session.ts","./src/runtime-implementation.ts","./src/runtime.ts","./src/session-getter.ts","./src/state-store.ts","./src/types.ts","./src/util.ts","./src/validate-client-metadata.ts","./src/errors/auth-method-unsatisfiable-error.ts","./src/errors/token-invalid-error.ts","./src/errors/token-refresh-error.ts","./src/errors/token-revoked-error.ts"],"version":"5.8.2"}