@atproto/oauth-provider 0.8.0 → 0.9.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 (77) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/client/client-auth.d.ts +48 -3
  3. package/dist/client/client-auth.d.ts.map +1 -1
  4. package/dist/client/client-auth.js +0 -31
  5. package/dist/client/client-auth.js.map +1 -1
  6. package/dist/client/client-manager.d.ts.map +1 -1
  7. package/dist/client/client-manager.js +19 -19
  8. package/dist/client/client-manager.js.map +1 -1
  9. package/dist/client/client.d.ts +14 -17
  10. package/dist/client/client.d.ts.map +1 -1
  11. package/dist/client/client.js +115 -73
  12. package/dist/client/client.js.map +1 -1
  13. package/dist/constants.d.ts +7 -6
  14. package/dist/constants.d.ts.map +1 -1
  15. package/dist/constants.js +8 -7
  16. package/dist/constants.js.map +1 -1
  17. package/dist/metadata/build-metadata.js +1 -1
  18. package/dist/metadata/build-metadata.js.map +1 -1
  19. package/dist/oauth-provider.d.ts +20 -16
  20. package/dist/oauth-provider.d.ts.map +1 -1
  21. package/dist/oauth-provider.js +268 -122
  22. package/dist/oauth-provider.js.map +1 -1
  23. package/dist/replay/replay-manager.d.ts +1 -1
  24. package/dist/replay/replay-manager.d.ts.map +1 -1
  25. package/dist/replay/replay-manager.js +5 -2
  26. package/dist/replay/replay-manager.js.map +1 -1
  27. package/dist/request/request-data.d.ts +3 -2
  28. package/dist/request/request-data.d.ts.map +1 -1
  29. package/dist/request/request-data.js.map +1 -1
  30. package/dist/request/request-info.d.ts +1 -1
  31. package/dist/request/request-info.d.ts.map +1 -1
  32. package/dist/request/request-manager.d.ts +73 -9
  33. package/dist/request/request-manager.d.ts.map +1 -1
  34. package/dist/request/request-manager.js +34 -61
  35. package/dist/request/request-manager.js.map +1 -1
  36. package/dist/request/request-store.d.ts +6 -2
  37. package/dist/request/request-store.d.ts.map +1 -1
  38. package/dist/request/request-store.js +6 -6
  39. package/dist/request/request-store.js.map +1 -1
  40. package/dist/router/create-api-middleware.js +1 -1
  41. package/dist/router/create-api-middleware.js.map +1 -1
  42. package/dist/router/create-oauth-middleware.d.ts.map +1 -1
  43. package/dist/router/create-oauth-middleware.js +2 -1
  44. package/dist/router/create-oauth-middleware.js.map +1 -1
  45. package/dist/token/token-data.d.ts +2 -2
  46. package/dist/token/token-data.d.ts.map +1 -1
  47. package/dist/token/token-manager.d.ts +10 -10
  48. package/dist/token/token-manager.d.ts.map +1 -1
  49. package/dist/token/token-manager.js +64 -201
  50. package/dist/token/token-manager.js.map +1 -1
  51. package/package.json +8 -7
  52. package/src/client/client-auth.ts +52 -33
  53. package/src/client/client-manager.ts +26 -27
  54. package/src/client/client.ts +153 -89
  55. package/src/constants.ts +9 -7
  56. package/src/metadata/build-metadata.ts +2 -2
  57. package/src/oauth-provider.ts +391 -191
  58. package/src/replay/replay-manager.ts +10 -6
  59. package/src/request/request-data.ts +12 -2
  60. package/src/request/request-info.ts +1 -1
  61. package/src/request/request-manager.ts +45 -85
  62. package/src/request/request-store.ts +11 -8
  63. package/src/router/create-api-middleware.ts +1 -1
  64. package/src/router/create-oauth-middleware.ts +7 -1
  65. package/src/token/token-data.ts +2 -2
  66. package/src/token/token-manager.ts +112 -312
  67. package/tsconfig.build.tsbuildinfo +1 -1
  68. package/dist/request/request-store-memory.d.ts +0 -16
  69. package/dist/request/request-store-memory.d.ts.map +0 -1
  70. package/dist/request/request-store-memory.js +0 -31
  71. package/dist/request/request-store-memory.js.map +0 -1
  72. package/dist/request/request-store-redis.d.ts +0 -24
  73. package/dist/request/request-store-redis.d.ts.map +0 -1
  74. package/dist/request/request-store-redis.js +0 -58
  75. package/dist/request/request-store-redis.js.map +0 -1
  76. package/src/request/request-store-memory.ts +0 -39
  77. package/src/request/request-store-redis.ts +0 -71
@@ -13,12 +13,16 @@ const asTimeFrame = (timeFrame: number) => Math.ceil(timeFrame * SECURITY_RATIO)
13
13
  export class ReplayManager {
14
14
  constructor(protected readonly replayStore: ReplayStore) {}
15
15
 
16
- async uniqueAuth(jti: string, clientId: ClientId): Promise<boolean> {
17
- return this.replayStore.unique(
18
- `Auth@${clientId}`,
19
- jti,
20
- asTimeFrame(CLIENT_ASSERTION_MAX_AGE),
21
- )
16
+ async uniqueAuth(
17
+ jti: string,
18
+ clientId: ClientId,
19
+ exp?: number,
20
+ ): Promise<boolean> {
21
+ const timeFrame =
22
+ exp == null
23
+ ? asTimeFrame(CLIENT_ASSERTION_MAX_AGE)
24
+ : exp * 1000 - Date.now()
25
+ return this.replayStore.unique(`Auth@${clientId}`, jti, timeFrame)
22
26
  }
23
27
 
24
28
  async uniqueJar(jti: string, clientId: ClientId): Promise<boolean> {
@@ -1,14 +1,24 @@
1
1
  import { OAuthAuthorizationRequestParameters } from '@atproto/oauth-types'
2
- import { ClientAuth } from '../client/client-auth.js'
2
+ import { ClientAuth, ClientAuthLegacy } from '../client/client-auth.js'
3
3
  import { ClientId } from '../client/client-id.js'
4
4
  import { DeviceId } from '../device/device-id.js'
5
5
  import { NonNullableKeys } from '../lib/util/type.js'
6
6
  import { Sub } from '../oidc/sub.js'
7
7
  import { Code } from './code.js'
8
8
 
9
+ export type {
10
+ ClientAuth,
11
+ ClientAuthLegacy,
12
+ ClientId,
13
+ Code,
14
+ DeviceId,
15
+ OAuthAuthorizationRequestParameters,
16
+ Sub,
17
+ }
18
+
9
19
  export type RequestData = {
10
20
  clientId: ClientId
11
- clientAuth: ClientAuth
21
+ clientAuth: null | ClientAuth | ClientAuthLegacy
12
22
  parameters: Readonly<OAuthAuthorizationRequestParameters>
13
23
  expiresAt: Date
14
24
  deviceId: DeviceId | null
@@ -10,5 +10,5 @@ export type RequestInfo = {
10
10
  parameters: Readonly<OAuthAuthorizationRequestParameters>
11
11
  expiresAt: Date
12
12
  clientId: ClientId
13
- clientAuth: ClientAuth
13
+ clientAuth: null | ClientAuth
14
14
  }
@@ -1,14 +1,16 @@
1
+ import { isAtprotoDid } from '@atproto/did'
1
2
  import type { Account } from '@atproto/oauth-provider-api'
2
3
  import {
3
- CLIENT_ASSERTION_TYPE_JWT_BEARER,
4
4
  OAuthAuthorizationRequestParameters,
5
5
  OAuthAuthorizationServerMetadata,
6
6
  } from '@atproto/oauth-types'
7
+ import { isValidHandle } from '@atproto/syntax'
7
8
  import { ClientAuth } from '../client/client-auth.js'
8
9
  import { ClientId } from '../client/client-id.js'
9
10
  import { Client } from '../client/client.js'
10
11
  import {
11
12
  AUTHORIZATION_INACTIVITY_TIMEOUT,
13
+ NODE_ENV,
12
14
  PAR_EXPIRES_IN,
13
15
  TOKEN_MAX_AGE,
14
16
  } from '../constants.js'
@@ -16,8 +18,6 @@ import { DeviceId } from '../device/device-id.js'
16
18
  import { AccessDeniedError } from '../errors/access-denied-error.js'
17
19
  import { ConsentRequiredError } from '../errors/consent-required-error.js'
18
20
  import { InvalidAuthorizationDetailsError } from '../errors/invalid-authorization-details-error.js'
19
- import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding-error.js'
20
- import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
21
21
  import { InvalidGrantError } from '../errors/invalid-grant-error.js'
22
22
  import { InvalidParametersError } from '../errors/invalid-parameters-error.js'
23
23
  import { InvalidRequestError } from '../errors/invalid-request-error.js'
@@ -25,7 +25,6 @@ import { InvalidScopeError } from '../errors/invalid-scope-error.js'
25
25
  import { RequestMetadata } from '../lib/http/request.js'
26
26
  import { callAsync } from '../lib/util/function.js'
27
27
  import { OAuthHooks } from '../oauth-hooks.js'
28
- import { DpopProof } from '../oauth-verifier.js'
29
28
  import { Signer } from '../signer/signer.js'
30
29
  import { Code, generateCode } from './code.js'
31
30
  import {
@@ -33,7 +32,6 @@ import {
33
32
  isRequestDataAuthorized,
34
33
  } from './request-data.js'
35
34
  import { generateRequestId } from './request-id.js'
36
- import { RequestInfo } from './request-info.js'
37
35
  import { RequestStore, UpdateRequestData } from './request-store.js'
38
36
  import {
39
37
  RequestUri,
@@ -56,21 +54,12 @@ export class RequestManager {
56
54
 
57
55
  async createAuthorizationRequest(
58
56
  client: Client,
59
- clientAuth: ClientAuth,
57
+ clientAuth: null | ClientAuth,
60
58
  input: Readonly<OAuthAuthorizationRequestParameters>,
61
59
  deviceId: null | DeviceId,
62
- dpopProof: null | DpopProof,
63
- ): Promise<RequestInfo> {
64
- const parameters = await this.validate(client, clientAuth, input, dpopProof)
65
- return this.create(client, clientAuth, parameters, deviceId)
66
- }
60
+ ) {
61
+ const parameters = await this.validate(client, clientAuth, input)
67
62
 
68
- protected async create(
69
- client: Client,
70
- clientAuth: ClientAuth,
71
- parameters: Readonly<OAuthAuthorizationRequestParameters>,
72
- deviceId: null | DeviceId = null,
73
- ): Promise<RequestInfo> {
74
63
  const expiresAt = new Date(Date.now() + PAR_EXPIRES_IN)
75
64
  const id = await generateRequestId()
76
65
 
@@ -85,14 +74,13 @@ export class RequestManager {
85
74
  })
86
75
 
87
76
  const uri = encodeRequestUri(id)
88
- return { id, uri, expiresAt, parameters, clientId: client.id, clientAuth }
77
+ return { uri, expiresAt, parameters }
89
78
  }
90
79
 
91
80
  protected async validate(
92
81
  client: Client,
93
- clientAuth: ClientAuth,
82
+ clientAuth: null | ClientAuth,
94
83
  parameters: Readonly<OAuthAuthorizationRequestParameters>,
95
- dpopProof: null | DpopProof,
96
84
  ): Promise<Readonly<OAuthAuthorizationRequestParameters>> {
97
85
  // -------------------------------
98
86
  // Validate unsupported parameters
@@ -197,24 +185,6 @@ export class RequestManager {
197
185
 
198
186
  parameters = { ...parameters, scope: [...scopes].join(' ') || undefined }
199
187
 
200
- // https://datatracker.ietf.org/doc/html/rfc9449#section-10
201
- if (!parameters.dpop_jkt) {
202
- if (dpopProof) parameters = { ...parameters, dpop_jkt: dpopProof.jkt }
203
- } else if (!dpopProof) {
204
- throw new InvalidDpopProofError('DPoP proof required')
205
- } else if (parameters.dpop_jkt !== dpopProof.jkt) {
206
- throw new InvalidDpopKeyBindingError()
207
- }
208
-
209
- if (clientAuth.method === CLIENT_ASSERTION_TYPE_JWT_BEARER) {
210
- if (parameters.dpop_jkt && clientAuth.jkt === parameters.dpop_jkt) {
211
- throw new InvalidParametersError(
212
- parameters,
213
- 'The DPoP proof must be signed with a different key than the client assertion',
214
- )
215
- }
216
- }
217
-
218
188
  if (parameters.code_challenge) {
219
189
  switch (parameters.code_challenge_method) {
220
190
  case undefined:
@@ -292,7 +262,7 @@ export class RequestManager {
292
262
  if (
293
263
  !client.info.isTrusted &&
294
264
  !client.info.isFirstParty &&
295
- clientAuth.method === 'none'
265
+ client.metadata.token_endpoint_auth_method === 'none'
296
266
  ) {
297
267
  if (parameters.prompt === 'none') {
298
268
  throw new ConsentRequiredError(
@@ -305,14 +275,28 @@ export class RequestManager {
305
275
  parameters = { ...parameters, prompt: 'consent' }
306
276
  }
307
277
 
278
+ // atproto extension: ensure that the login_hint is a valid handle or DID
279
+ // @NOTE we to allow invalid case here, which is not spec'd anywhere.
280
+ const hint = parameters.login_hint?.toLowerCase()
281
+ if (hint) {
282
+ if (!isAtprotoDid(hint) && !isValidHandle(hint)) {
283
+ throw new InvalidParametersError(
284
+ parameters,
285
+ `Invalid login_hint "${hint}"`,
286
+ )
287
+ }
288
+
289
+ // @TODO: ensure that the account actually exists on this server (there is
290
+ // no point in showing the UI to the user if the account does not exist).
291
+
292
+ // Update the parameters to ensure the right case is used
293
+ parameters = { ...parameters, login_hint: hint }
294
+ }
295
+
308
296
  return parameters
309
297
  }
310
298
 
311
- async get(
312
- uri: RequestUri,
313
- deviceId: DeviceId,
314
- clientId?: ClientId,
315
- ): Promise<RequestInfo> {
299
+ async get(uri: RequestUri, deviceId: DeviceId, clientId?: ClientId) {
316
300
  const id = decodeRequestUri(uri)
317
301
 
318
302
  const data = await this.store.readRequest(id)
@@ -363,12 +347,10 @@ export class RequestManager {
363
347
  }
364
348
 
365
349
  return {
366
- id,
367
350
  uri,
368
351
  expiresAt: updates.expiresAt || data.expiresAt,
369
352
  parameters: data.parameters,
370
353
  clientId: data.clientId,
371
- clientAuth: data.clientAuth,
372
354
  }
373
355
  }
374
356
 
@@ -438,53 +420,31 @@ export class RequestManager {
438
420
  * @note If this method throws an error, any token previously generated from
439
421
  * the same `code` **must** me revoked.
440
422
  */
441
- public async findCode(
442
- client: Client,
443
- clientAuth: ClientAuth,
444
- code: Code,
445
- ): Promise<RequestDataAuthorized & { requestUri: RequestUri }> {
446
- const result = await this.store.findRequestByCode(code)
423
+ public async consumeCode(code: Code): Promise<RequestDataAuthorized> {
424
+ const result = await this.store.consumeRequestCode(code)
447
425
  if (!result) throw new InvalidGrantError('Invalid code')
448
426
 
449
427
  const { id, data } = result
450
- try {
451
- if (!isRequestDataAuthorized(data)) {
452
- // Should never happen: maybe the store implementation is faulty ?
453
- throw new Error('Unexpected request state')
454
- }
455
428
 
456
- if (data.clientId !== client.id) {
457
- // Note: do not reveal the original client ID to the client using an invalid id
458
- throw new InvalidGrantError(
459
- `The code was not issued to client "${client.id}"`,
460
- )
461
- }
462
-
463
- if (data.expiresAt < new Date()) {
464
- throw new InvalidGrantError('This code has expired')
429
+ // Fool-proofing the store implementation against code replay attacks (in
430
+ // case consumeRequestCode() does not delete the request).
431
+ if (NODE_ENV !== 'production') {
432
+ const result = await this.store.readRequest(id)
433
+ if (result) {
434
+ throw new Error('Invalid store implementation: request not deleted')
465
435
  }
436
+ }
466
437
 
467
- if (data.clientAuth.method === 'none') {
468
- // If the client did not use PAR, it was not authenticated when the
469
- // request was created (see authorize() method above). Since PAR is not
470
- // mandatory, and since the token exchange currently taking place *is*
471
- // authenticated (`clientAuth`), we allow "upgrading" the authentication
472
- // method (the token created will be bound to the current clientAuth).
473
- } else {
474
- if (clientAuth.method !== data.clientAuth.method) {
475
- throw new InvalidGrantError('Invalid client authentication')
476
- }
477
-
478
- if (!(await client.validateClientAuth(data.clientAuth))) {
479
- throw new InvalidGrantError('Invalid client authentication')
480
- }
481
- }
438
+ if (!isRequestDataAuthorized(data) || data.code !== code) {
439
+ // Should never happen: maybe the store implementation is faulty ?
440
+ throw new Error('Unexpected request state')
441
+ }
482
442
 
483
- return { ...data, requestUri: encodeRequestUri(id) }
484
- } finally {
485
- // A "code" can only be used once
486
- await this.store.deleteRequest(id)
443
+ if (data.expiresAt < new Date()) {
444
+ throw new InvalidGrantError('This code has expired')
487
445
  }
446
+
447
+ return data
488
448
  }
489
449
 
490
450
  async delete(uri: RequestUri): Promise<void> {
@@ -32,10 +32,14 @@ export interface RequestStore {
32
32
  updateRequest(id: RequestId, data: UpdateRequestData): Awaitable<void>
33
33
  deleteRequest(id: RequestId): void | Awaitable<void>
34
34
  /**
35
+ * @note it is **IMPORTANT** that this method prevents concurrent retrieval of
36
+ * the same code. If two requests are made with the same code, only one of
37
+ * them should succeed and return the request data.
38
+ *
35
39
  * @throws {InvalidGrantError} - When the request is not found or has expired
36
40
  * (allows to provide an error message instead of returning `null`).
37
41
  */
38
- findRequestByCode(code: Code): Awaitable<FoundRequestResult | null>
42
+ consumeRequestCode(code: Code): Awaitable<FoundRequestResult | null>
39
43
  }
40
44
 
41
45
  export const isRequestStore = buildInterfaceChecker<RequestStore>([
@@ -43,15 +47,14 @@ export const isRequestStore = buildInterfaceChecker<RequestStore>([
43
47
  'readRequest',
44
48
  'updateRequest',
45
49
  'deleteRequest',
46
- 'findRequestByCode',
50
+ 'consumeRequestCode',
47
51
  ])
48
52
 
49
- export function ifRequestStore<V extends Partial<RequestStore>>(
53
+ export function asRequestStore<V extends Partial<RequestStore>>(
50
54
  implementation?: V,
51
- ): (V & RequestStore) | undefined {
52
- if (implementation && isRequestStore(implementation)) {
53
- return implementation
55
+ ): V & RequestStore {
56
+ if (!implementation || !isRequestStore(implementation)) {
57
+ throw new Error('Invalid RequestStore implementation')
54
58
  }
55
-
56
- return undefined
59
+ return implementation
57
60
  }
@@ -367,7 +367,7 @@ export function createApiMiddleware<
367
367
  this.input.tokenId,
368
368
  )
369
369
 
370
- if (tokenInfo.account.sub !== account.sub) {
370
+ if (!tokenInfo || tokenInfo.account.sub !== account.sub) {
371
371
  // report this as though the token was not found
372
372
  throw new InvalidRequestError(`Invalid token`)
373
373
  }
@@ -168,8 +168,14 @@ export function createOAuthMiddleware<
168
168
  .parseAsync(payload, { path: ['body'] })
169
169
  .catch(throwInvalidRequest)
170
170
 
171
+ const dpopProof = await server.checkDpopProof(
172
+ req.method!,
173
+ this.url,
174
+ req.headers,
175
+ )
176
+
171
177
  try {
172
- await server.revoke(credentials, tokenIdentification)
178
+ await server.revoke(credentials, tokenIdentification, dpopProof)
173
179
  } catch (err) {
174
180
  // > Note: invalid tokens do not cause an error response since the
175
181
  // > client cannot handle such an error in a reasonable way. Moreover,
@@ -2,7 +2,7 @@ import {
2
2
  OAuthAuthorizationDetails,
3
3
  OAuthAuthorizationRequestParameters,
4
4
  } from '@atproto/oauth-types'
5
- import { ClientAuth } from '../client/client-auth.js'
5
+ import { ClientAuth, ClientAuthLegacy } from '../client/client-auth.js'
6
6
  import { ClientId } from '../client/client-id.js'
7
7
  import { DeviceId } from '../device/device-id.js'
8
8
  import { Sub } from '../oidc/sub.js'
@@ -23,7 +23,7 @@ export type TokenData = {
23
23
  updatedAt: Date
24
24
  expiresAt: Date
25
25
  clientId: ClientId
26
- clientAuth: ClientAuth
26
+ clientAuth: ClientAuth | ClientAuthLegacy
27
27
  deviceId: DeviceId | null
28
28
  sub: Sub
29
29
  parameters: OAuthAuthorizationRequestParameters