@atproto/oauth-provider 0.8.1 → 0.9.1

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 +48 -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 -34
  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 +9 -8
  14. package/dist/constants.d.ts.map +1 -1
  15. package/dist/constants.js +10 -9
  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 +20 -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 +7 -7
  52. package/src/client/client-auth.ts +52 -33
  53. package/src/client/client-manager.ts +26 -46
  54. package/src/client/client.ts +153 -89
  55. package/src/constants.ts +10 -8
  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 +25 -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,7 +1,6 @@
1
1
  import { isAtprotoDid } from '@atproto/did'
2
2
  import type { Account } from '@atproto/oauth-provider-api'
3
3
  import {
4
- CLIENT_ASSERTION_TYPE_JWT_BEARER,
5
4
  OAuthAuthorizationRequestParameters,
6
5
  OAuthAuthorizationServerMetadata,
7
6
  } from '@atproto/oauth-types'
@@ -11,6 +10,7 @@ import { ClientId } from '../client/client-id.js'
11
10
  import { Client } from '../client/client.js'
12
11
  import {
13
12
  AUTHORIZATION_INACTIVITY_TIMEOUT,
13
+ NODE_ENV,
14
14
  PAR_EXPIRES_IN,
15
15
  TOKEN_MAX_AGE,
16
16
  } from '../constants.js'
@@ -18,8 +18,6 @@ import { DeviceId } from '../device/device-id.js'
18
18
  import { AccessDeniedError } from '../errors/access-denied-error.js'
19
19
  import { ConsentRequiredError } from '../errors/consent-required-error.js'
20
20
  import { InvalidAuthorizationDetailsError } from '../errors/invalid-authorization-details-error.js'
21
- import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding-error.js'
22
- import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
23
21
  import { InvalidGrantError } from '../errors/invalid-grant-error.js'
24
22
  import { InvalidParametersError } from '../errors/invalid-parameters-error.js'
25
23
  import { InvalidRequestError } from '../errors/invalid-request-error.js'
@@ -27,7 +25,6 @@ import { InvalidScopeError } from '../errors/invalid-scope-error.js'
27
25
  import { RequestMetadata } from '../lib/http/request.js'
28
26
  import { callAsync } from '../lib/util/function.js'
29
27
  import { OAuthHooks } from '../oauth-hooks.js'
30
- import { DpopProof } from '../oauth-verifier.js'
31
28
  import { Signer } from '../signer/signer.js'
32
29
  import { Code, generateCode } from './code.js'
33
30
  import {
@@ -35,7 +32,6 @@ import {
35
32
  isRequestDataAuthorized,
36
33
  } from './request-data.js'
37
34
  import { generateRequestId } from './request-id.js'
38
- import { RequestInfo } from './request-info.js'
39
35
  import { RequestStore, UpdateRequestData } from './request-store.js'
40
36
  import {
41
37
  RequestUri,
@@ -58,21 +54,12 @@ export class RequestManager {
58
54
 
59
55
  async createAuthorizationRequest(
60
56
  client: Client,
61
- clientAuth: ClientAuth,
57
+ clientAuth: null | ClientAuth,
62
58
  input: Readonly<OAuthAuthorizationRequestParameters>,
63
59
  deviceId: null | DeviceId,
64
- dpopProof: null | DpopProof,
65
- ): Promise<RequestInfo> {
66
- const parameters = await this.validate(client, clientAuth, input, dpopProof)
67
- return this.create(client, clientAuth, parameters, deviceId)
68
- }
60
+ ) {
61
+ const parameters = await this.validate(client, clientAuth, input)
69
62
 
70
- protected async create(
71
- client: Client,
72
- clientAuth: ClientAuth,
73
- parameters: Readonly<OAuthAuthorizationRequestParameters>,
74
- deviceId: null | DeviceId = null,
75
- ): Promise<RequestInfo> {
76
63
  const expiresAt = new Date(Date.now() + PAR_EXPIRES_IN)
77
64
  const id = await generateRequestId()
78
65
 
@@ -87,14 +74,13 @@ export class RequestManager {
87
74
  })
88
75
 
89
76
  const uri = encodeRequestUri(id)
90
- return { id, uri, expiresAt, parameters, clientId: client.id, clientAuth }
77
+ return { uri, expiresAt, parameters }
91
78
  }
92
79
 
93
80
  protected async validate(
94
81
  client: Client,
95
- clientAuth: ClientAuth,
82
+ clientAuth: null | ClientAuth,
96
83
  parameters: Readonly<OAuthAuthorizationRequestParameters>,
97
- dpopProof: null | DpopProof,
98
84
  ): Promise<Readonly<OAuthAuthorizationRequestParameters>> {
99
85
  // -------------------------------
100
86
  // Validate unsupported parameters
@@ -199,24 +185,6 @@ export class RequestManager {
199
185
 
200
186
  parameters = { ...parameters, scope: [...scopes].join(' ') || undefined }
201
187
 
202
- // https://datatracker.ietf.org/doc/html/rfc9449#section-10
203
- if (!parameters.dpop_jkt) {
204
- if (dpopProof) parameters = { ...parameters, dpop_jkt: dpopProof.jkt }
205
- } else if (!dpopProof) {
206
- throw new InvalidDpopProofError('DPoP proof required')
207
- } else if (parameters.dpop_jkt !== dpopProof.jkt) {
208
- throw new InvalidDpopKeyBindingError()
209
- }
210
-
211
- if (clientAuth.method === CLIENT_ASSERTION_TYPE_JWT_BEARER) {
212
- if (parameters.dpop_jkt && clientAuth.jkt === parameters.dpop_jkt) {
213
- throw new InvalidParametersError(
214
- parameters,
215
- 'The DPoP proof must be signed with a different key than the client assertion',
216
- )
217
- }
218
- }
219
-
220
188
  if (parameters.code_challenge) {
221
189
  switch (parameters.code_challenge_method) {
222
190
  case undefined:
@@ -294,7 +262,7 @@ export class RequestManager {
294
262
  if (
295
263
  !client.info.isTrusted &&
296
264
  !client.info.isFirstParty &&
297
- clientAuth.method === 'none'
265
+ client.metadata.token_endpoint_auth_method === 'none'
298
266
  ) {
299
267
  if (parameters.prompt === 'none') {
300
268
  throw new ConsentRequiredError(
@@ -328,11 +296,7 @@ export class RequestManager {
328
296
  return parameters
329
297
  }
330
298
 
331
- async get(
332
- uri: RequestUri,
333
- deviceId: DeviceId,
334
- clientId?: ClientId,
335
- ): Promise<RequestInfo> {
299
+ async get(uri: RequestUri, deviceId: DeviceId, clientId?: ClientId) {
336
300
  const id = decodeRequestUri(uri)
337
301
 
338
302
  const data = await this.store.readRequest(id)
@@ -383,12 +347,10 @@ export class RequestManager {
383
347
  }
384
348
 
385
349
  return {
386
- id,
387
350
  uri,
388
351
  expiresAt: updates.expiresAt || data.expiresAt,
389
352
  parameters: data.parameters,
390
353
  clientId: data.clientId,
391
- clientAuth: data.clientAuth,
392
354
  }
393
355
  }
394
356
 
@@ -458,53 +420,31 @@ export class RequestManager {
458
420
  * @note If this method throws an error, any token previously generated from
459
421
  * the same `code` **must** me revoked.
460
422
  */
461
- public async findCode(
462
- client: Client,
463
- clientAuth: ClientAuth,
464
- code: Code,
465
- ): Promise<RequestDataAuthorized & { requestUri: RequestUri }> {
466
- const result = await this.store.findRequestByCode(code)
423
+ public async consumeCode(code: Code): Promise<RequestDataAuthorized> {
424
+ const result = await this.store.consumeRequestCode(code)
467
425
  if (!result) throw new InvalidGrantError('Invalid code')
468
426
 
469
427
  const { id, data } = result
470
- try {
471
- if (!isRequestDataAuthorized(data)) {
472
- // Should never happen: maybe the store implementation is faulty ?
473
- throw new Error('Unexpected request state')
474
- }
475
428
 
476
- if (data.clientId !== client.id) {
477
- // Note: do not reveal the original client ID to the client using an invalid id
478
- throw new InvalidGrantError(
479
- `The code was not issued to client "${client.id}"`,
480
- )
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')
481
435
  }
436
+ }
482
437
 
483
- if (data.expiresAt < new Date()) {
484
- throw new InvalidGrantError('This code has expired')
485
- }
486
-
487
- if (data.clientAuth.method === 'none') {
488
- // If the client did not use PAR, it was not authenticated when the
489
- // request was created (see authorize() method above). Since PAR is not
490
- // mandatory, and since the token exchange currently taking place *is*
491
- // authenticated (`clientAuth`), we allow "upgrading" the authentication
492
- // method (the token created will be bound to the current clientAuth).
493
- } else {
494
- if (clientAuth.method !== data.clientAuth.method) {
495
- throw new InvalidGrantError('Invalid client authentication')
496
- }
497
-
498
- if (!(await client.validateClientAuth(data.clientAuth))) {
499
- throw new InvalidGrantError('Invalid client authentication')
500
- }
501
- }
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
+ }
502
442
 
503
- return { ...data, requestUri: encodeRequestUri(id) }
504
- } finally {
505
- // A "code" can only be used once
506
- await this.store.deleteRequest(id)
443
+ if (data.expiresAt < new Date()) {
444
+ throw new InvalidGrantError('This code has expired')
507
445
  }
446
+
447
+ return data
508
448
  }
509
449
 
510
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