@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
@@ -1,4 +1,6 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import type { Redis, RedisOptions } from 'ioredis'
3
+ import { ZodError } from 'zod'
2
4
  import { Jwks, Keyset } from '@atproto/jwk'
3
5
  import type { Account } from '@atproto/oauth-provider-api'
4
6
  import {
@@ -33,7 +35,7 @@ import {
33
35
  DeviceAccount,
34
36
  asAccountStore,
35
37
  } from './account/account-store.js'
36
- import { ClientAuth, authJwkThumbprint } from './client/client-auth.js'
38
+ import { ClientAuth, ClientAuthLegacy } from './client/client-auth.js'
37
39
  import { ClientId } from './client/client-id.js'
38
40
  import {
39
41
  ClientManager,
@@ -41,7 +43,14 @@ import {
41
43
  } from './client/client-manager.js'
42
44
  import { ClientStore, ifClientStore } from './client/client-store.js'
43
45
  import { Client } from './client/client.js'
44
- import { AUTHENTICATION_MAX_AGE, TOKEN_MAX_AGE } from './constants.js'
46
+ import {
47
+ AUTHENTICATION_MAX_AGE,
48
+ CONFIDENTIAL_CLIENT_REFRESH_LIFETIME,
49
+ CONFIDENTIAL_CLIENT_SESSION_LIFETIME,
50
+ PUBLIC_CLIENT_REFRESH_LIFETIME,
51
+ PUBLIC_CLIENT_SESSION_LIFETIME,
52
+ TOKEN_MAX_AGE,
53
+ } from './constants.js'
45
54
  import { Branding, BrandingInput } from './customization/branding.js'
46
55
  import {
47
56
  Customization,
@@ -58,8 +67,9 @@ import { DeviceStore, asDeviceStore } from './device/device-store.js'
58
67
  import { AccessDeniedError } from './errors/access-denied-error.js'
59
68
  import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js'
60
69
  import { ConsentRequiredError } from './errors/consent-required-error.js'
70
+ import { InvalidDpopKeyBindingError } from './errors/invalid-dpop-key-binding-error.js'
71
+ import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js'
61
72
  import { InvalidGrantError } from './errors/invalid-grant-error.js'
62
- import { InvalidParametersError } from './errors/invalid-parameters-error.js'
63
73
  import { InvalidRequestError } from './errors/invalid-request-error.js'
64
74
  import { LoginRequiredError } from './errors/login-required-error.js'
65
75
  import { HcaptchaConfig } from './lib/hcaptcha.js'
@@ -76,18 +86,20 @@ import {
76
86
  } from './oauth-verifier.js'
77
87
  import { ReplayStore, ifReplayStore } from './replay/replay-store.js'
78
88
  import { codeSchema } from './request/code.js'
79
- import { RequestInfo } from './request/request-info.js'
80
89
  import { RequestManager } from './request/request-manager.js'
81
- import { RequestStoreMemory } from './request/request-store-memory.js'
82
- import { RequestStoreRedis } from './request/request-store-redis.js'
83
- import { RequestStore, ifRequestStore } from './request/request-store.js'
90
+ import { RequestStore, asRequestStore } from './request/request-store.js'
84
91
  import { requestUriSchema } from './request/request-uri.js'
85
92
  import { AuthorizationRedirectParameters } from './result/authorization-redirect-parameters.js'
86
93
  import { AuthorizationResultAuthorizePage } from './result/authorization-result-authorize-page.js'
87
94
  import { AuthorizationResultRedirect } from './result/authorization-result-redirect.js'
88
95
  import { ErrorHandler } from './router/error-handler.js'
96
+ import { TokenData } from './token/token-data.js'
89
97
  import { TokenManager } from './token/token-manager.js'
90
- import { TokenStore, asTokenStore } from './token/token-store.js'
98
+ import {
99
+ TokenStore,
100
+ asTokenStore,
101
+ refreshTokenSchema,
102
+ } from './token/token-store.js'
91
103
  import {
92
104
  VerifyTokenClaimsOptions,
93
105
  VerifyTokenClaimsResult,
@@ -242,18 +254,17 @@ export class OAuthProvider extends OAuthVerifier {
242
254
  metadata,
243
255
 
244
256
  safeFetch = safeFetchWrap(),
245
- redis,
246
257
  store, // compound store implementation
247
258
 
248
259
  // Requires stores
249
260
  accountStore = asAccountStore(store),
250
261
  deviceStore = asDeviceStore(store),
251
262
  tokenStore = asTokenStore(store),
263
+ requestStore = asRequestStore(store),
252
264
 
253
265
  // These are optional
254
266
  clientStore = ifClientStore(store),
255
267
  replayStore = ifReplayStore(store),
256
- requestStore = ifRequestStore(store),
257
268
 
258
269
  clientJwksCache = new SimpleStoreMemory({
259
270
  maxSize: 50_000_000,
@@ -288,11 +299,7 @@ export class OAuthProvider extends OAuthVerifier {
288
299
  // be the responsibility of the super class.
289
300
  const superOptions: OAuthVerifierOptions = rest
290
301
 
291
- super({ replayStore, redis, ...superOptions })
292
-
293
- requestStore ??= redis
294
- ? new RequestStoreRedis({ redis })
295
- : new RequestStoreMemory()
302
+ super({ replayStore, ...superOptions })
296
303
 
297
304
  this.accessTokenMode = accessTokenMode
298
305
  this.authenticationMaxAge = authenticationMaxAge
@@ -363,97 +370,92 @@ export class OAuthProvider extends OAuthVerifier {
363
370
  }
364
371
 
365
372
  protected async authenticateClient(
366
- credentials: OAuthClientCredentials,
367
- ): Promise<[Client, ClientAuth]> {
368
- const client = await this.clientManager.getClient(credentials.client_id)
369
- const { clientAuth, nonce } = await client.verifyCredentials(credentials, {
370
- audience: this.issuer,
371
- })
373
+ clientCredentials: OAuthClientCredentials,
374
+ dpopProof: null | DpopProof,
375
+ options?: {
376
+ allowMissingDpopProof?: boolean
377
+ },
378
+ ): Promise<{
379
+ client: Client
380
+ clientAuth: ClientAuth
381
+ }> {
382
+ const client = await this.clientManager.getClient(
383
+ clientCredentials.client_id,
384
+ )
372
385
 
373
386
  if (
374
- client.metadata.application_type === 'native' &&
375
- clientAuth.method !== 'none'
387
+ client.metadata.dpop_bound_access_tokens &&
388
+ !dpopProof &&
389
+ !options?.allowMissingDpopProof
376
390
  ) {
377
- // https://datatracker.ietf.org/doc/html/rfc8252#section-8.4
378
- //
379
- // > Except when using a mechanism like Dynamic Client Registration
380
- // > [RFC7591] to provision per-instance secrets, native apps are
381
- // > classified as public clients, as defined by Section 2.1 of OAuth 2.0
382
- // > [RFC6749]; they MUST be registered with the authorization server as
383
- // > such. Authorization servers MUST record the client type in the client
384
- // > registration details in order to identify and process requests
385
- // > accordingly.
391
+ throw new InvalidDpopProofError('DPoP proof required')
392
+ }
386
393
 
387
- throw new InvalidGrantError(
388
- 'Native clients must authenticate using "none" method',
389
- )
394
+ if (dpopProof && !client.metadata.dpop_bound_access_tokens) {
395
+ throw new InvalidDpopProofError('DPoP proof not allowed for this client')
390
396
  }
391
397
 
392
- if (nonce != null) {
393
- const unique = await this.replayManager.uniqueAuth(nonce, client.id)
398
+ const clientAuth = await client.authenticate(clientCredentials, {
399
+ authorizationServerIdentifier: this.issuer,
400
+ })
401
+
402
+ if (clientAuth.method === 'private_key_jwt') {
403
+ // Clients MUST NOT use their client assertion key to sign DPoP proofs
404
+ if (dpopProof && clientAuth.jkt === dpopProof.jkt) {
405
+ throw new InvalidRequestError(
406
+ 'The DPoP proof must be signed with a different key than the client assertion',
407
+ )
408
+ }
409
+
410
+ // https://www.rfc-editor.org/rfc/rfc7523.html#section-3
411
+ // > 7. [...] The authorization server MAY ensure that JWTs are not
412
+ // > replayed by maintaining the set of used "jti" values for the
413
+ // > length of time for which the JWT would be considered valid based
414
+ // > on the applicable "exp" instant.
415
+
416
+ const unique = await this.replayManager.uniqueAuth(
417
+ clientAuth.jti,
418
+ client.id,
419
+ clientAuth.exp,
420
+ )
394
421
  if (!unique) {
395
422
  throw new InvalidGrantError(`${clientAuth.method} jti reused`)
396
423
  }
397
424
  }
398
425
 
399
- return [client, clientAuth]
426
+ return { client, clientAuth }
400
427
  }
401
428
 
402
429
  protected async decodeJAR(
403
430
  client: Client,
404
431
  input: OAuthAuthorizationRequestJar,
405
- ): Promise<
406
- | {
407
- payload: OAuthAuthorizationRequestParameters
408
- }
409
- | {
410
- payload: OAuthAuthorizationRequestParameters
411
- protectedHeader: { kid: string; alg: string }
412
- jkt: string
413
- }
414
- > {
415
- const result = await client.decodeRequestObject(input.request)
416
- const payload = oauthAuthorizationRequestParametersSchema.parse(
417
- result.payload,
432
+ ): Promise<OAuthAuthorizationRequestParameters> {
433
+ const { payload } = await client.decodeRequestObject(
434
+ input.request,
435
+ this.issuer,
418
436
  )
419
437
 
420
- if (!result.payload.jti) {
421
- throw new InvalidParametersError(
422
- payload,
423
- 'Request object must contain a jti claim',
438
+ const { jti } = payload
439
+ if (!jti) {
440
+ throw new InvalidRequestError(
441
+ 'Request object payload must contain a "jti" claim',
424
442
  )
425
443
  }
426
-
427
- if (!(await this.replayManager.uniqueJar(result.payload.jti, client.id))) {
428
- throw new InvalidParametersError(
429
- payload,
430
- 'Request object jti is not unique',
431
- )
444
+ if (!(await this.replayManager.uniqueJar(jti, client.id))) {
445
+ throw new InvalidRequestError('Request object was replayed')
432
446
  }
433
447
 
434
- if ('protectedHeader' in result) {
435
- if (!result.protectedHeader.kid) {
436
- throw new InvalidParametersError(payload, 'Missing "kid" in header')
437
- }
438
-
439
- return {
440
- jkt: await authJwkThumbprint(result.key),
441
- payload,
442
- protectedHeader: result.protectedHeader as {
443
- alg: string
444
- kid: string
445
- },
446
- }
447
- }
448
-
449
- if ('header' in result) {
450
- return {
451
- payload,
452
- }
453
- }
454
-
455
- // Should never happen
456
- throw new Error('Invalid request object')
448
+ const parameters = await oauthAuthorizationRequestParametersSchema
449
+ .parseAsync(payload)
450
+ .catch((err) => {
451
+ const message =
452
+ err instanceof ZodError
453
+ ? `Invalid request parameters: ${err.message}`
454
+ : `Invalid "request" object`
455
+ throw InvalidRequestError.from(err, message)
456
+ })
457
+
458
+ return parameters
457
459
  }
458
460
 
459
461
  /**
@@ -465,12 +467,43 @@ export class OAuthProvider extends OAuthVerifier {
465
467
  dpopProof: null | DpopProof,
466
468
  ): Promise<OAuthParResponse> {
467
469
  try {
468
- const [client, clientAuth] = await this.authenticateClient(credentials)
470
+ const { client, clientAuth } = await this.authenticateClient(
471
+ credentials,
472
+ dpopProof,
473
+ // Allow missing DPoP header for PAR requests as rfc9449 allows it
474
+ // (though the dpop_jkt parameter must be present in that case, see
475
+ // check bellow).
476
+ { allowMissingDpopProof: true },
477
+ )
469
478
 
470
- const { payload: parameters } =
479
+ const parameters =
471
480
  'request' in authorizationRequest // Handle JAR
472
481
  ? await this.decodeJAR(client, authorizationRequest)
473
- : { payload: authorizationRequest }
482
+ : authorizationRequest
483
+
484
+ if (!parameters.dpop_jkt) {
485
+ if (client.metadata.dpop_bound_access_tokens) {
486
+ if (dpopProof) parameters.dpop_jkt = dpopProof.jkt
487
+ else {
488
+ // @NOTE When both PAR and DPoP are used, either the DPoP header, or
489
+ // the dpop_jkt parameter must be present. We do not enforce this
490
+ // for legacy reasons.
491
+ // https://datatracker.ietf.org/doc/html/rfc9449#section-10.1
492
+ }
493
+ }
494
+ } else {
495
+ if (!client.metadata.dpop_bound_access_tokens) {
496
+ throw new InvalidRequestError(
497
+ 'DPoP bound access tokens are not enabled for this client',
498
+ )
499
+ }
500
+
501
+ // Proof is optional if the dpop_jkt is provided, but if it is provided,
502
+ // it must match the DPoP proof JKT.
503
+ if (dpopProof && dpopProof.jkt !== parameters.dpop_jkt) {
504
+ throw new InvalidDpopKeyBindingError()
505
+ }
506
+ }
474
507
 
475
508
  const { uri, expiresAt } =
476
509
  await this.requestManager.createAuthorizationRequest(
@@ -478,7 +511,6 @@ export class OAuthProvider extends OAuthVerifier {
478
511
  clientAuth,
479
512
  parameters,
480
513
  null,
481
- dpopProof,
482
514
  )
483
515
 
484
516
  return {
@@ -501,7 +533,8 @@ export class OAuthProvider extends OAuthVerifier {
501
533
  client: Client,
502
534
  deviceId: DeviceId,
503
535
  query: OAuthAuthorizationRequestQuery,
504
- ): Promise<RequestInfo> {
536
+ ) {
537
+ // PAR
505
538
  if ('request_uri' in query) {
506
539
  const requestUri = await requestUriSchema
507
540
  .parseAsync(query.request_uri, { path: ['query', 'request_uri'] })
@@ -515,43 +548,35 @@ export class OAuthProvider extends OAuthVerifier {
515
548
  return this.requestManager.get(requestUri, deviceId, client.id)
516
549
  }
517
550
 
551
+ // JAR
518
552
  if ('request' in query) {
519
- const requestObject = await this.decodeJAR(client, query)
520
-
521
- if ('protectedHeader' in requestObject && requestObject.protectedHeader) {
522
- // Allow using signed JAR during "/authorize" as client authentication.
523
- // This allows clients to skip PAR to initiate trusted sessions.
524
- const clientAuth: ClientAuth = {
525
- method: CLIENT_ASSERTION_TYPE_JWT_BEARER,
526
- kid: requestObject.protectedHeader.kid,
527
- alg: requestObject.protectedHeader.alg,
528
- jkt: requestObject.jkt,
529
- }
530
-
531
- return this.requestManager.createAuthorizationRequest(
532
- client,
533
- clientAuth,
534
- requestObject.payload,
535
- deviceId,
536
- null,
537
- )
538
- }
553
+ // @NOTE Since JAR are signed with the client's private key, a JAR *could*
554
+ // technically be used to authenticate the client when requests are
555
+ // created without PAR (i.e. created on the fly by the authorize
556
+ // endpoint). This implementation actually used to support this
557
+ // (un-spec'd) behavior. That support was removed:
558
+ // - Because it was not actually used
559
+ // - Because it was not part of any standard
560
+ // - Because it makes extending the client authentication mechanism more
561
+ // complex since any extension would not only need to affect the
562
+ // "private_key_jwt" auth method but also the JAR "request" object.
563
+ const parameters = await this.decodeJAR(client, query)
539
564
 
540
565
  return this.requestManager.createAuthorizationRequest(
541
566
  client,
542
- { method: 'none' },
543
- requestObject.payload,
544
- deviceId,
545
567
  null,
568
+ parameters,
569
+ deviceId,
546
570
  )
547
571
  }
548
572
 
573
+ // "Regular" authorization request (created on the fly by directing the user
574
+ // to the authorization endpoint with all the parameters in the url).
549
575
  return this.requestManager.createAuthorizationRequest(
550
576
  client,
551
- { method: 'none' },
577
+ null,
552
578
  query,
553
579
  deviceId,
554
- null,
555
580
  )
556
581
  }
557
582
 
@@ -723,8 +748,10 @@ export class OAuthProvider extends OAuthVerifier {
723
748
  request: OAuthTokenRequest,
724
749
  dpopProof: null | DpopProof,
725
750
  ): Promise<OAuthTokenResponse> {
726
- const [client, clientAuth] =
727
- await this.authenticateClient(clientCredentials)
751
+ const { client, clientAuth } = await this.authenticateClient(
752
+ clientCredentials,
753
+ dpopProof,
754
+ )
728
755
 
729
756
  if (!this.metadata.grant_types_supported?.includes(request.grant_type)) {
730
757
  throw new InvalidGrantError(
@@ -739,7 +766,7 @@ export class OAuthProvider extends OAuthVerifier {
739
766
  }
740
767
 
741
768
  if (request.grant_type === 'authorization_code') {
742
- return this.codeGrant(
769
+ return this.authorizationCodeGrant(
743
770
  client,
744
771
  clientAuth,
745
772
  clientMetadata,
@@ -763,116 +790,289 @@ export class OAuthProvider extends OAuthVerifier {
763
790
  )
764
791
  }
765
792
 
766
- protected async codeGrant(
793
+ protected async compareClientAuth(
794
+ client: Client,
795
+ clientAuth: ClientAuth,
796
+ dpopProof: null | DpopProof,
797
+ initial: {
798
+ parameters: OAuthAuthorizationRequestParameters
799
+ clientId: ClientId
800
+ clientAuth: null | ClientAuth | ClientAuthLegacy
801
+ },
802
+ ): Promise<void> {
803
+ // Fool proofing, ensure that the client is authenticating using the right method
804
+ if (clientAuth.method !== client.metadata.token_endpoint_auth_method) {
805
+ throw new InvalidGrantError(
806
+ `Client authentication method mismatch (expected ${client.metadata.token_endpoint_auth_method}, got ${clientAuth.method})`,
807
+ )
808
+ }
809
+
810
+ if (initial.clientId !== client.id) {
811
+ throw new InvalidGrantError(`Token was not issued to this client`)
812
+ }
813
+
814
+ const { parameters } = initial
815
+ if (parameters.dpop_jkt) {
816
+ if (!dpopProof) {
817
+ throw new InvalidGrantError(`DPoP proof is required for this request`)
818
+ } else if (parameters.dpop_jkt !== dpopProof.jkt) {
819
+ throw new InvalidGrantError(
820
+ `DPoP proof does not match the expected JKT`,
821
+ )
822
+ }
823
+ }
824
+
825
+ if (!initial.clientAuth) {
826
+ // If the client did not use PAR, it was not authenticated when the request
827
+ // was initially created (see authorize() method in OAuthProvider). Since
828
+ // PAR is not mandatory, and since the token exchange currently taking place
829
+ // *is* authenticated (`clientAuth`), we allow "upgrading" the
830
+ // authentication method (the token created will be bound to the current
831
+ // clientAuth).
832
+ return
833
+ }
834
+
835
+ switch (initial.clientAuth.method) {
836
+ case CLIENT_ASSERTION_TYPE_JWT_BEARER: // LEGACY
837
+ case 'private_key_jwt':
838
+ if (clientAuth.method !== 'private_key_jwt') {
839
+ throw new InvalidGrantError(
840
+ `Client authentication method mismatch (expected ${initial.clientAuth.method})`,
841
+ )
842
+ }
843
+ if (
844
+ clientAuth.kid !== initial.clientAuth.kid ||
845
+ clientAuth.alg !== initial.clientAuth.alg ||
846
+ clientAuth.jkt !== initial.clientAuth.jkt
847
+ ) {
848
+ throw new InvalidGrantError(
849
+ `The session was initiated with a different key than the client assertion currently used`,
850
+ )
851
+ }
852
+ break
853
+ case 'none':
854
+ // @NOTE We allow the client to "upgrade" to a confidential client if
855
+ // the session was initially created without client authentication.
856
+ break
857
+ default:
858
+ throw new InvalidGrantError(
859
+ // @ts-expect-error (future proof, backwards compatibility)
860
+ `Invalid method "${initial.clientAuth.method}"`,
861
+ )
862
+ }
863
+ }
864
+
865
+ protected async authorizationCodeGrant(
767
866
  client: Client,
768
867
  clientAuth: ClientAuth,
769
868
  clientMetadata: RequestMetadata,
770
869
  input: OAuthAuthorizationCodeGrantTokenRequest,
771
870
  dpopProof: null | DpopProof,
772
871
  ): Promise<OAuthTokenResponse> {
773
- const code = codeSchema.parse(input.code)
774
- try {
775
- const { sub, deviceId, parameters } = await this.requestManager.findCode(
776
- client,
777
- clientAuth,
778
- code,
872
+ const code = await codeSchema
873
+ .parseAsync(input.code, { path: ['code'] })
874
+ .catch((err) => {
875
+ throw InvalidGrantError.from(
876
+ err,
877
+ err instanceof ZodError
878
+ ? `Invalid code: ${err.message}`
879
+ : `Invalid code`,
880
+ )
881
+ })
882
+
883
+ const data = await this.requestManager
884
+ .consumeCode(code)
885
+ .catch(async (err) => {
886
+ // Code not found in request manager: check for replays
887
+ const tokenInfo = await this.tokenManager.findByCode(code)
888
+ if (tokenInfo) {
889
+ // try/finally to ensure that both code path get executed (sequentially)
890
+ try {
891
+ // "code" was replayed, delete existing session
892
+ await this.tokenManager.deleteToken(tokenInfo.id)
893
+ } finally {
894
+ // As an additional security measure, we also sign the device out,
895
+ // so that the device cannot be used to access the account anymore
896
+ // without a new authentication.
897
+ const { deviceId, sub } = tokenInfo.data
898
+ if (deviceId) {
899
+ await this.accountManager.removeDeviceAccount(deviceId, sub)
900
+ }
901
+ }
902
+ }
903
+
904
+ throw InvalidGrantError.from(err, `Invalid code`)
905
+ })
906
+
907
+ // @NOTE at this point, the request data was removed from the store and only
908
+ // exists in memory here (in the "data" variable). Because of this, any
909
+ // error thrown after this point will permanently cause the request data to
910
+ // be lost.
911
+
912
+ await this.compareClientAuth(client, clientAuth, dpopProof, data)
913
+
914
+ // If the DPoP proof was not provided earlier (PAR / authorize), let's add
915
+ // it now.
916
+ const parameters =
917
+ dpopProof &&
918
+ client.metadata.dpop_bound_access_tokens &&
919
+ !data.parameters.dpop_jkt
920
+ ? { ...data.parameters, dpop_jkt: dpopProof.jkt }
921
+ : data.parameters
922
+
923
+ await this.validateCodeGrant(parameters, input)
924
+
925
+ const { account } = await this.accountManager.getAccount(data.sub)
926
+
927
+ return this.tokenManager.createToken(
928
+ client,
929
+ clientAuth,
930
+ clientMetadata,
931
+ account,
932
+ data.deviceId,
933
+ parameters,
934
+ code,
935
+ )
936
+ }
937
+
938
+ protected async validateCodeGrant(
939
+ parameters: OAuthAuthorizationRequestParameters,
940
+ input: OAuthAuthorizationCodeGrantTokenRequest,
941
+ ): Promise<void> {
942
+ if (parameters.redirect_uri !== input.redirect_uri) {
943
+ throw new InvalidGrantError(
944
+ 'The redirect_uri parameter must match the one used in the authorization request',
779
945
  )
946
+ }
780
947
 
781
- // the following check prevents re-use of PKCE challenges, enforcing the
782
- // clients to generate a new challenge for each authorization request. The
783
- // replay manager typically prevents replay over a certain time frame,
784
- // which might not cover the entire lifetime of the token (depending on
785
- // the implementation of the replay store). For this reason, we should
786
- // ideally ensure that the code_challenge was not already used by any
787
- // existing token or any other pending request.
788
- //
789
- // The current implementation will cause client devs not issuing a new
790
- // code challenge for each authorization request to fail, which should be
791
- // a good enough incentive to follow the best practices, until we have a
792
- // better implementation.
793
- //
794
- // @TODO Use tokenManager to ensure uniqueness of code_challenge
795
- if (parameters.code_challenge) {
796
- const unique = await this.replayManager.uniqueCodeChallenge(
797
- parameters.code_challenge,
798
- )
799
- if (!unique) {
800
- throw new InvalidGrantError('Code challenge already used')
948
+ if (parameters.code_challenge) {
949
+ if (!input.code_verifier) {
950
+ throw new InvalidGrantError('code_verifier is required')
951
+ }
952
+ if (input.code_verifier.length < 43) {
953
+ throw new InvalidGrantError('code_verifier too short')
954
+ }
955
+ switch (parameters.code_challenge_method) {
956
+ case undefined: // default is "plain"
957
+ case 'plain':
958
+ if (parameters.code_challenge !== input.code_verifier) {
959
+ throw new InvalidGrantError('Invalid code_verifier')
960
+ }
961
+ break
962
+
963
+ case 'S256': {
964
+ const inputChallenge = Buffer.from(
965
+ parameters.code_challenge,
966
+ 'base64',
967
+ )
968
+ const computedChallenge = createHash('sha256')
969
+ .update(input.code_verifier)
970
+ .digest()
971
+ if (inputChallenge.compare(computedChallenge) !== 0) {
972
+ throw new InvalidGrantError('Invalid code_verifier')
973
+ }
974
+ break
801
975
  }
976
+
977
+ default:
978
+ // Should never happen (because request validation should catch this)
979
+ throw new Error(`Unsupported code_challenge_method`)
802
980
  }
981
+ const unique = await this.replayManager.uniqueCodeChallenge(
982
+ parameters.code_challenge,
983
+ )
984
+ if (!unique) {
985
+ throw new InvalidGrantError('Code challenge already used')
986
+ }
987
+ } else if (input.code_verifier !== undefined) {
988
+ throw new InvalidRequestError("code_challenge parameter wasn't provided")
989
+ }
990
+ }
803
991
 
804
- const { account } = await this.accountManager.getAccount(sub)
992
+ protected async refreshTokenGrant(
993
+ client: Client,
994
+ clientAuth: ClientAuth,
995
+ clientMetadata: RequestMetadata,
996
+ input: OAuthRefreshTokenGrantTokenRequest,
997
+ dpopProof: null | DpopProof,
998
+ ): Promise<OAuthTokenResponse> {
999
+ const refreshToken = await refreshTokenSchema
1000
+ .parseAsync(input.refresh_token, { path: ['refresh_token'] })
1001
+ .catch((err) => {
1002
+ throw InvalidGrantError.from(err, `Invalid refresh token`)
1003
+ })
805
1004
 
806
- return await this.tokenManager.create(
1005
+ const tokenInfo = await this.tokenManager.consumeRefreshToken(refreshToken)
1006
+
1007
+ try {
1008
+ const { data } = tokenInfo
1009
+ await this.compareClientAuth(client, clientAuth, dpopProof, data)
1010
+ await this.validateRefreshGrant(client, clientAuth, data)
1011
+
1012
+ return await this.tokenManager.rotateToken(
807
1013
  client,
808
1014
  clientAuth,
809
1015
  clientMetadata,
810
- account,
811
- deviceId,
812
- parameters,
813
- input,
814
- dpopProof,
1016
+ tokenInfo,
815
1017
  )
816
1018
  } catch (err) {
817
- // If a token is replayed, requestManager.findCode will throw. In that
818
- // case, we need to revoke any token that was issued for this code.
819
-
820
- const tokenInfo = await this.tokenManager.findByCode(code)
821
- if (tokenInfo) {
822
- await this.tokenManager.deleteToken(tokenInfo.id)
823
-
824
- // As an additional security measure, we also sign the device out, so
825
- // that the device cannot be used to access the account anymore without
826
- // a new authentication.
827
- const { deviceId, sub } = tokenInfo.data
828
- if (deviceId) {
829
- await this.accountManager.removeDeviceAccount(deviceId, sub)
830
- }
831
- }
1019
+ await this.tokenManager.deleteToken(tokenInfo.id)
832
1020
 
833
1021
  throw err
834
1022
  }
835
1023
  }
836
1024
 
837
- async refreshTokenGrant(
1025
+ protected async validateRefreshGrant(
838
1026
  client: Client,
839
1027
  clientAuth: ClientAuth,
840
- clientMetadata: RequestMetadata,
841
- input: OAuthRefreshTokenGrantTokenRequest,
842
- dpopProof: null | DpopProof,
843
- ): Promise<OAuthTokenResponse> {
844
- return this.tokenManager.refresh(
845
- client,
846
- clientAuth,
847
- clientMetadata,
848
- input,
849
- dpopProof,
850
- )
1028
+ data: TokenData,
1029
+ ): Promise<void> {
1030
+ const [sessionLifetime, refreshLifetime] =
1031
+ clientAuth.method !== 'none' || client.info.isFirstParty
1032
+ ? [
1033
+ CONFIDENTIAL_CLIENT_SESSION_LIFETIME,
1034
+ CONFIDENTIAL_CLIENT_REFRESH_LIFETIME,
1035
+ ]
1036
+ : [PUBLIC_CLIENT_SESSION_LIFETIME, PUBLIC_CLIENT_REFRESH_LIFETIME]
1037
+
1038
+ const sessionAge = Date.now() - data.createdAt.getTime()
1039
+ if (sessionAge > sessionLifetime) {
1040
+ throw new InvalidGrantError(`Session expired`)
1041
+ }
1042
+
1043
+ const refreshAge = Date.now() - data.updatedAt.getTime()
1044
+ if (refreshAge > refreshLifetime) {
1045
+ throw new InvalidGrantError(`Refresh token expired`)
1046
+ }
851
1047
  }
852
1048
 
853
1049
  /**
854
1050
  * @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 rfc7009}
855
1051
  */
856
1052
  public async revoke(
857
- credentials: OAuthClientCredentials,
1053
+ clientCredentials: OAuthClientCredentials,
858
1054
  { token }: OAuthTokenIdentification,
1055
+ dpopProof: null | DpopProof,
859
1056
  ) {
860
1057
  // > The authorization server first validates the client credentials (in
861
1058
  // > case of a confidential client)
862
- const [client, clientAuth] = await this.authenticateClient(credentials)
1059
+ const { client, clientAuth } = await this.authenticateClient(
1060
+ clientCredentials,
1061
+ dpopProof,
1062
+ )
863
1063
 
864
1064
  const tokenInfo = await this.tokenManager.findToken(token)
865
-
866
- // > [...] and then verifies whether the token was issued to the client
867
- // > making the revocation request. If this validation fails, the request is
868
- // > refused and the client is informed of the error by the authorization
869
- // > server as described below.
870
- await this.tokenManager.validateAccess(client, clientAuth, tokenInfo)
871
-
872
- // > In the next step, the authorization server invalidates the token. The
873
- // > invalidation takes place immediately, and the token cannot be used
874
- // > again after the revocation.
875
- await this.tokenManager.deleteToken(tokenInfo.id)
1065
+ if (tokenInfo) {
1066
+ // > [...] and then verifies whether the token was issued to the client
1067
+ // > making the revocation request.
1068
+ const { data } = tokenInfo
1069
+ await this.compareClientAuth(client, clientAuth, dpopProof, data)
1070
+
1071
+ // > In the next step, the authorization server invalidates the token. The
1072
+ // > invalidation takes place immediately, and the token cannot be used
1073
+ // > again after the revocation.
1074
+ await this.tokenManager.deleteToken(tokenInfo.id)
1075
+ }
876
1076
  }
877
1077
 
878
1078
  protected override async verifyToken(