@atproto/oauth-provider 0.1.3 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (120) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/account/account.d.ts +6 -2
  3. package/dist/account/account.d.ts.map +1 -1
  4. package/dist/assets/app/bundle-manifest.json +3 -3
  5. package/dist/assets/app/main.css +1 -1
  6. package/dist/assets/app/main.js +1 -1
  7. package/dist/assets/app/main.js.map +1 -1
  8. package/dist/assets/assets-middleware.d.ts +2 -1
  9. package/dist/assets/assets-middleware.d.ts.map +1 -1
  10. package/dist/assets/assets-middleware.js +7 -0
  11. package/dist/assets/assets-middleware.js.map +1 -1
  12. package/dist/client/client-manager.d.ts +4 -3
  13. package/dist/client/client-manager.d.ts.map +1 -1
  14. package/dist/client/client-manager.js +60 -37
  15. package/dist/client/client-manager.js.map +1 -1
  16. package/dist/client/client.d.ts.map +1 -1
  17. package/dist/client/client.js +1 -3
  18. package/dist/client/client.js.map +1 -1
  19. package/dist/constants.d.ts +2 -0
  20. package/dist/constants.d.ts.map +1 -1
  21. package/dist/constants.js +3 -1
  22. package/dist/constants.js.map +1 -1
  23. package/dist/device/device-manager.d.ts +1 -1
  24. package/dist/device/device-manager.d.ts.map +1 -1
  25. package/dist/device/device-manager.js +2 -2
  26. package/dist/device/device-manager.js.map +1 -1
  27. package/dist/errors/invalid-authorization-details-error.d.ts +4 -3
  28. package/dist/errors/invalid-authorization-details-error.d.ts.map +1 -1
  29. package/dist/errors/invalid-authorization-details-error.js +4 -4
  30. package/dist/errors/invalid-authorization-details-error.js.map +1 -1
  31. package/dist/lib/http/request.d.ts +3 -0
  32. package/dist/lib/http/request.d.ts.map +1 -1
  33. package/dist/lib/http/request.js +24 -12
  34. package/dist/lib/http/request.js.map +1 -1
  35. package/dist/metadata/build-metadata.d.ts +0 -1
  36. package/dist/metadata/build-metadata.d.ts.map +1 -1
  37. package/dist/metadata/build-metadata.js +9 -35
  38. package/dist/metadata/build-metadata.js.map +1 -1
  39. package/dist/oauth-hooks.d.ts +3 -10
  40. package/dist/oauth-hooks.d.ts.map +1 -1
  41. package/dist/oauth-provider.d.ts +8 -13
  42. package/dist/oauth-provider.d.ts.map +1 -1
  43. package/dist/oauth-provider.js +169 -109
  44. package/dist/oauth-provider.js.map +1 -1
  45. package/dist/oauth-verifier.d.ts +1 -2
  46. package/dist/oauth-verifier.d.ts.map +1 -1
  47. package/dist/oauth-verifier.js.map +1 -1
  48. package/dist/output/build-authorize-data.d.ts +6 -0
  49. package/dist/output/build-authorize-data.d.ts.map +1 -1
  50. package/dist/output/build-authorize-data.js +1 -0
  51. package/dist/output/build-authorize-data.js.map +1 -1
  52. package/dist/replay/replay-manager.d.ts +1 -0
  53. package/dist/replay/replay-manager.d.ts.map +1 -1
  54. package/dist/replay/replay-manager.js +3 -0
  55. package/dist/replay/replay-manager.js.map +1 -1
  56. package/dist/replay/replay-store.d.ts +1 -1
  57. package/dist/request/request-info.d.ts +2 -0
  58. package/dist/request/request-info.d.ts.map +1 -1
  59. package/dist/request/request-manager.d.ts +3 -9
  60. package/dist/request/request-manager.d.ts.map +1 -1
  61. package/dist/request/request-manager.js +52 -77
  62. package/dist/request/request-manager.js.map +1 -1
  63. package/dist/request/types.d.ts +10 -10
  64. package/dist/signer/signed-token-payload.d.ts +85 -85
  65. package/dist/signer/signer.d.ts +23 -30
  66. package/dist/signer/signer.d.ts.map +1 -1
  67. package/dist/signer/signer.js +0 -40
  68. package/dist/signer/signer.js.map +1 -1
  69. package/dist/token/token-claims.d.ts +81 -81
  70. package/dist/token/token-manager.d.ts +1 -2
  71. package/dist/token/token-manager.d.ts.map +1 -1
  72. package/dist/token/token-manager.js +10 -37
  73. package/dist/token/token-manager.js.map +1 -1
  74. package/dist/token/types.d.ts +10 -10
  75. package/package.json +2 -3
  76. package/src/account/account.ts +11 -7
  77. package/src/assets/app/backend-data.ts +9 -2
  78. package/src/assets/app/components/accept-form.tsx +65 -51
  79. package/src/assets/app/components/client-name.tsx +24 -16
  80. package/src/assets/app/views/accept-view.tsx +7 -4
  81. package/src/assets/app/views/authorize-view.tsx +2 -1
  82. package/src/assets/assets-middleware.ts +14 -2
  83. package/src/client/client-manager.ts +78 -60
  84. package/src/client/client.ts +1 -4
  85. package/src/constants.ts +3 -0
  86. package/src/device/device-manager.ts +7 -1
  87. package/src/errors/invalid-authorization-details-error.ts +9 -4
  88. package/src/lib/http/request.ts +61 -15
  89. package/src/metadata/build-metadata.ts +9 -42
  90. package/src/oauth-hooks.ts +3 -13
  91. package/src/oauth-provider.ts +181 -159
  92. package/src/oauth-verifier.ts +1 -2
  93. package/src/output/build-authorize-data.ts +8 -0
  94. package/src/replay/replay-manager.ts +9 -0
  95. package/src/replay/replay-store.ts +1 -1
  96. package/src/request/request-info.ts +2 -0
  97. package/src/request/request-manager.ts +81 -107
  98. package/src/signer/signer.ts +0 -63
  99. package/src/token/token-manager.ts +8 -41
  100. package/dist/oidc/claims.d.ts +0 -16
  101. package/dist/oidc/claims.d.ts.map +0 -1
  102. package/dist/oidc/claims.js +0 -29
  103. package/dist/oidc/claims.js.map +0 -1
  104. package/dist/oidc/userinfo.d.ts +0 -7
  105. package/dist/oidc/userinfo.d.ts.map +0 -1
  106. package/dist/oidc/userinfo.js +0 -3
  107. package/dist/oidc/userinfo.js.map +0 -1
  108. package/dist/parameters/claims-requested.d.ts +0 -3
  109. package/dist/parameters/claims-requested.d.ts.map +0 -1
  110. package/dist/parameters/claims-requested.js +0 -77
  111. package/dist/parameters/claims-requested.js.map +0 -1
  112. package/dist/parameters/oidc-payload.d.ts +0 -31
  113. package/dist/parameters/oidc-payload.d.ts.map +0 -1
  114. package/dist/parameters/oidc-payload.js +0 -25
  115. package/dist/parameters/oidc-payload.js.map +0 -1
  116. package/src/assets/app/components/client-identifier.tsx +0 -31
  117. package/src/oidc/claims.ts +0 -35
  118. package/src/oidc/userinfo.ts +0 -11
  119. package/src/parameters/claims-requested.ts +0 -106
  120. package/src/parameters/oidc-payload.ts +0 -28
@@ -1,7 +1,7 @@
1
1
  import { safeFetchWrap } from '@atproto-labs/fetch-node'
2
2
  import { SimpleStore } from '@atproto-labs/simple-store'
3
3
  import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
4
- import { Jwks, Keyset, SignedJwt, signedJwtSchema } from '@atproto/jwk'
4
+ import { Jwks, Keyset } from '@atproto/jwk'
5
5
  import {
6
6
  AccessToken,
7
7
  CLIENT_ASSERTION_TYPE_JWT_BEARER,
@@ -15,12 +15,11 @@ import {
15
15
  oauthAuthenticationRequestParametersSchema,
16
16
  } from '@atproto/oauth-types'
17
17
  import { Redis, type RedisOptions } from 'ioredis'
18
- import { z } from 'zod'
18
+ import z, { ZodError } from 'zod'
19
19
 
20
20
  import { AccessTokenType } from './access-token/access-token-type.js'
21
21
  import { AccountManager } from './account/account-manager.js'
22
22
  import {
23
- AccountInfo,
24
23
  AccountStore,
25
24
  DeviceAccountInfo,
26
25
  SignInCredentials,
@@ -58,12 +57,13 @@ import {
58
57
  Middleware,
59
58
  Router,
60
59
  ServerResponse,
61
- acceptMiddleware,
62
60
  combineMiddlewares,
63
61
  setupCsrfToken,
64
62
  staticJsonHandler,
65
63
  validateCsrfToken,
64
+ validateFetchDest,
66
65
  validateFetchMode,
66
+ validateFetchSite,
67
67
  validateReferer,
68
68
  validateRequestPayload,
69
69
  validateSameOrigin,
@@ -74,7 +74,6 @@ import { Override } from './lib/util/type.js'
74
74
  import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js'
75
75
  import { OAuthHooks } from './oauth-hooks.js'
76
76
  import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js'
77
- import { Userinfo } from './oidc/userinfo.js'
78
77
  import { AuthorizationResultAuthorize } from './output/build-authorize-data.js'
79
78
  import {
80
79
  buildErrorPayload,
@@ -86,7 +85,6 @@ import {
86
85
  AuthorizationResultRedirect,
87
86
  sendAuthorizeRedirect,
88
87
  } from './output/send-authorize-redirect.js'
89
- import { oidcPayload } from './parameters/oidc-payload.js'
90
88
  import { ReplayStore, ifReplayStore } from './replay/replay-store.js'
91
89
  import { RequestInfo } from './request/request-info.js'
92
90
  import { RequestManager } from './request/request-manager.js'
@@ -103,7 +101,7 @@ import {
103
101
  } from './request/types.js'
104
102
  import { isTokenId } from './token/token-id.js'
105
103
  import { TokenManager } from './token/token-manager.js'
106
- import { TokenInfo, TokenStore, asTokenStore } from './token/token-store.js'
104
+ import { TokenStore, asTokenStore } from './token/token-store.js'
107
105
  import {
108
106
  CodeGrantRequest,
109
107
  Introspect,
@@ -146,9 +144,7 @@ export type OAuthProviderOptions = Override<
146
144
  {
147
145
  /**
148
146
  * Maximum age a device/account session can be before requiring
149
- * re-authentication. This can be overridden on a authorization request basis
150
- * using the `max_age` parameter and on a client basis using the
151
- * `default_max_age` client metadata.
147
+ * re-authentication.
152
148
  */
153
149
  authenticationMaxAge?: number
154
150
 
@@ -286,6 +282,7 @@ export class OAuthProvider extends OAuthVerifier {
286
282
 
287
283
  this.accountManager = new AccountManager(accountStore)
288
284
  this.clientManager = new ClientManager(
285
+ this.metadata,
289
286
  this.keyset,
290
287
  rest,
291
288
  clientStore || null,
@@ -326,14 +323,7 @@ export class OAuthProvider extends OAuthVerifier {
326
323
  return true
327
324
  }
328
325
 
329
- /** in seconds */
330
- const maxAge = parameters.max_age ?? client.metadata.default_max_age
331
-
332
- if (maxAge != null && maxAge < this.authenticationMaxAge) {
333
- return authAge >= maxAge
334
- } else {
335
- return authAge >= this.authenticationMaxAge
336
- }
326
+ return authAge >= this.authenticationMaxAge
337
327
  }
338
328
 
339
329
  protected async authenticateClient(
@@ -551,15 +541,14 @@ export class OAuthProvider extends OAuthVerifier {
551
541
  throw new ConsentRequiredError(parameters)
552
542
  }
553
543
 
554
- const redirect = await this.requestManager.setAuthorized(
544
+ const code = await this.requestManager.setAuthorized(
555
545
  client,
556
546
  uri,
557
547
  deviceId,
558
548
  ssoSession.account,
559
- ssoSession.info,
560
549
  )
561
550
 
562
- return { issuer, client, parameters, redirect }
551
+ return { issuer, client, parameters, redirect: { code } }
563
552
  }
564
553
 
565
554
  // Automatic SSO when a did was provided
@@ -568,15 +557,14 @@ export class OAuthProvider extends OAuthVerifier {
568
557
  if (ssoSessions.length === 1) {
569
558
  const ssoSession = ssoSessions[0]!
570
559
  if (!ssoSession.loginRequired && !ssoSession.consentRequired) {
571
- const redirect = await this.requestManager.setAuthorized(
560
+ const code = await this.requestManager.setAuthorized(
572
561
  client,
573
562
  uri,
574
563
  deviceId,
575
564
  ssoSession.account,
576
- ssoSession.info,
577
565
  )
578
566
 
579
- return { issuer, client, parameters, redirect }
567
+ return { issuer, client, parameters, redirect: { code } }
580
568
  }
581
569
  }
582
570
  }
@@ -585,7 +573,20 @@ export class OAuthProvider extends OAuthVerifier {
585
573
  issuer,
586
574
  client,
587
575
  parameters,
588
- authorize: { uri, sessions },
576
+ authorize: {
577
+ uri,
578
+ sessions,
579
+ scopeDetails: parameters.scope
580
+ ?.split(/\s+/)
581
+ .filter(Boolean)
582
+ .sort((a, b) => a.localeCompare(b))
583
+ .map((scope) => ({
584
+ scope,
585
+ // @TODO Allow to customize the scope descriptions (e.g.
586
+ // using a hook)
587
+ description: undefined,
588
+ })),
589
+ },
589
590
  }
590
591
  } catch (err) {
591
592
  await this.deleteRequest(uri, parameters)
@@ -652,6 +653,9 @@ export class OAuthProvider extends OAuthVerifier {
652
653
  this.loginRequired(client, parameters, info),
653
654
  consentRequired:
654
655
  parameters.prompt === 'consent' ||
656
+ // @TODO the "authorizedClients" should also include the scopes that
657
+ // were already authorized for the client. Otherwise a client could
658
+ // use silent authentication to get additional scopes without consent.
655
659
  !info.authorizedClients.includes(client.id),
656
660
 
657
661
  matchesHint: hint == null || matchesHint(account),
@@ -660,9 +664,33 @@ export class OAuthProvider extends OAuthVerifier {
660
664
 
661
665
  protected async signIn(
662
666
  deviceId: DeviceId,
667
+ uri: RequestUri,
668
+ clientId: ClientId,
663
669
  credentials: SignInCredentials,
664
- ): Promise<AccountInfo> {
665
- return this.accountManager.signIn(credentials, deviceId)
670
+ ): Promise<{
671
+ account: Account
672
+ consentRequired: boolean
673
+ }> {
674
+ const client = await this.clientManager.getClient(clientId)
675
+
676
+ // Ensure the request is still valid (and update the request expiration)
677
+ // @TODO use the returned scopes to determine if consent is required
678
+ await this.requestManager.get(uri, clientId, deviceId)
679
+
680
+ const { account, info } = await this.accountManager.signIn(
681
+ credentials,
682
+ deviceId,
683
+ )
684
+
685
+ return {
686
+ account,
687
+ consentRequired: client.info.isFirstParty
688
+ ? false
689
+ : // @TODO: the "authorizedClients" should also include the scopes that
690
+ // were already authorized for the client. Otherwise a client could
691
+ // use silent authentication to get additional scopes without consent.
692
+ !info.authorizedClients.includes(client.id),
693
+ }
666
694
  }
667
695
 
668
696
  protected async acceptRequest(
@@ -692,12 +720,11 @@ export class OAuthProvider extends OAuthVerifier {
692
720
  )
693
721
  }
694
722
 
695
- const redirect = await this.requestManager.setAuthorized(
723
+ const code = await this.requestManager.setAuthorized(
696
724
  client,
697
725
  uri,
698
726
  deviceId,
699
727
  account,
700
- info,
701
728
  )
702
729
 
703
730
  await this.accountManager.addAuthorizedClient(
@@ -707,7 +734,7 @@ export class OAuthProvider extends OAuthVerifier {
707
734
  clientAuth,
708
735
  )
709
736
 
710
- return { issuer, client, parameters, redirect }
737
+ return { issuer, client, parameters, redirect: { code } }
711
738
  } catch (err) {
712
739
  await this.deleteRequest(uri, parameters)
713
740
 
@@ -794,6 +821,32 @@ export class OAuthProvider extends OAuthVerifier {
794
821
  input.code,
795
822
  )
796
823
 
824
+ // the following check prevents re-use of PKCE challenges, enforcing the
825
+ // clients to generate a new challenge for each authorization request. The
826
+ // replay manager typically prevents replay over a certain time frame,
827
+ // which might not cover the entire lifetime of the token (depending on
828
+ // the implementation of the replay store). For this reason, we should
829
+ // ideally ensure that the code_challenge was not already used by any
830
+ // existing token or any other pending request.
831
+ //
832
+ // The current implementation will cause client devs not issuing a new
833
+ // code challenge for each authorization request to fail, which should be
834
+ // a good enough incentive to follow the best practices, until we have a
835
+ // better implementation.
836
+ //
837
+ // @TODO: Use tokenManager to ensure uniqueness of code_challenge
838
+ if (parameters.code_challenge) {
839
+ const unique = await this.replayManager.uniqueCodeChallenge(
840
+ parameters.code_challenge,
841
+ )
842
+ if (!unique) {
843
+ throw new InvalidGrantError(
844
+ 'code_challenge',
845
+ 'Code challenge already used',
846
+ )
847
+ }
848
+ }
849
+
797
850
  const { account, info } = await this.accountManager.get(deviceId, sub)
798
851
 
799
852
  return await this.tokenManager.create(
@@ -891,31 +944,6 @@ export class OAuthProvider extends OAuthVerifier {
891
944
  }
892
945
  }
893
946
 
894
- /**
895
- * @see {@link https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.2 Successful UserInfo Response}
896
- */
897
- protected async userinfo({ data, account }: TokenInfo): Promise<Userinfo> {
898
- return {
899
- ...oidcPayload(data.parameters, account),
900
-
901
- sub: account.sub,
902
-
903
- client_id: data.clientId,
904
- username: account.preferred_username,
905
- }
906
- }
907
-
908
- protected async signUserinfo(userinfo: Userinfo): Promise<SignedJwt> {
909
- const client = await this.clientManager.getClient(userinfo.client_id)
910
- return this.signer.sign(
911
- {
912
- alg: client.metadata.userinfo_signed_response_alg,
913
- typ: 'JWT',
914
- },
915
- userinfo,
916
- )
917
- }
918
-
919
947
  protected override async authenticateToken(
920
948
  tokenType: OAuthTokenType,
921
949
  token: AccessToken,
@@ -979,6 +1007,8 @@ export class OAuthProvider extends OAuthVerifier {
979
1007
  combineMiddlewares([
980
1008
  function (req, res, next) {
981
1009
  res.setHeader('Access-Control-Allow-Origin', '*')
1010
+ res.setHeader('Access-Control-Allow-Headers', '*')
1011
+
982
1012
  res.setHeader('Cache-Control', 'max-age=300')
983
1013
  next()
984
1014
  },
@@ -995,6 +1025,7 @@ export class OAuthProvider extends OAuthVerifier {
995
1025
  ): Handler<T, TReq, TRes> =>
996
1026
  async function (req, res) {
997
1027
  res.setHeader('Access-Control-Allow-Origin', '*')
1028
+ res.setHeader('Access-Control-Allow-Headers', '*')
998
1029
 
999
1030
  // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1
1000
1031
  res.setHeader('Cache-Control', 'no-store')
@@ -1037,11 +1068,15 @@ export class OAuthProvider extends OAuthVerifier {
1037
1068
  handler: (this: T, req: TReq, res: TRes) => void | Promise<void>,
1038
1069
  ): Handler<T, TReq, TRes> =>
1039
1070
  async function (req, res) {
1071
+ res.setHeader('Access-Control-Allow-Origin', '*')
1072
+ res.setHeader('Access-Control-Allow-Headers', '*')
1073
+
1040
1074
  res.setHeader('Cache-Control', 'no-store')
1041
1075
  res.setHeader('Pragma', 'no-cache')
1042
1076
 
1043
1077
  try {
1044
1078
  validateFetchMode(req, res, ['navigate'])
1079
+ validateFetchDest(req, res, ['document'])
1045
1080
  validateSameOrigin(req, res, issuerOrigin)
1046
1081
 
1047
1082
  await handler.call(this, req, res)
@@ -1066,49 +1101,45 @@ export class OAuthProvider extends OAuthVerifier {
1066
1101
 
1067
1102
  //- Public OAuth endpoints
1068
1103
 
1069
- /*
1070
- * Although OpenID compatibility is not required to implement the Atproto
1071
- * OAuth2 specification, we do support OIDC discovery in this
1072
- * implementation as we believe this may:
1073
- * 1) Make the implementation of Atproto clients easier (since lots of
1074
- * libraries support OIDC discovery)
1075
- * 2) Allow self hosted PDS' to not implement authentication themselves
1076
- * but rely on a trusted Atproto actor to act as their OIDC providers.
1077
- * By supporting OIDC in the current implementation, Bluesky's
1078
- * Authorization Server server can be used as an OIDC provider for
1079
- * these users.
1080
- */
1081
- router.get('/.well-known/openid-configuration', staticJson(server.metadata))
1082
-
1083
1104
  router.get(
1084
1105
  '/.well-known/oauth-authorization-server',
1085
1106
  staticJson(server.metadata),
1086
1107
  )
1087
1108
 
1088
1109
  // CORS preflight
1089
- router.options<{
1090
- endpoint: 'jwks' | 'par' | 'token' | 'revoke' | 'introspect' | 'userinfo'
1091
- }>(
1092
- /^\/oauth\/(?<endpoint>jwks|par|token|revoke|introspect|userinfo)$/,
1093
- function (req, res, _next) {
1094
- res
1095
- .writeHead(204, {
1096
- 'Access-Control-Allow-Origin': req.headers['origin'] || '*',
1097
- 'Access-Control-Allow-Methods':
1098
- this.params.endpoint === 'jwks' ? 'GET' : 'POST',
1099
- 'Access-Control-Allow-Headers': 'Content-Type,Authorization,DPoP',
1100
- 'Access-Control-Max-Age': '86400', // 1 day
1101
- })
1102
- .end()
1103
- },
1104
- )
1110
+ const corsPreflight: Middleware = function (req, res, _next) {
1111
+ res
1112
+ .writeHead(204, {
1113
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
1114
+ //
1115
+ // > For requests without credentials, the literal value "*" can be
1116
+ // > specified as a wildcard; the value tells browsers to allow
1117
+ // > requesting code from any origin to access the resource.
1118
+ // > Attempting to use the wildcard with credentials results in an
1119
+ // > error.
1120
+ //
1121
+ // A "*" is safer to use than reflecting the request origin.
1122
+ 'Access-Control-Allow-Origin': '*',
1123
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
1124
+ // > The value "*" only counts as a special wildcard value for
1125
+ // > requests without credentials (requests without HTTP cookies or
1126
+ // > HTTP authentication information). In requests with credentials,
1127
+ // > it is treated as the literal method name "*" without special
1128
+ // > semantics.
1129
+ 'Access-Control-Allow-Methods': '*',
1130
+ 'Access-Control-Allow-Headers': 'Content-Type,Authorization,DPoP',
1131
+ 'Access-Control-Max-Age': '86400', // 1 day
1132
+ })
1133
+ .end()
1134
+ }
1105
1135
 
1106
1136
  router.get('/oauth/jwks', staticJson(server.jwks))
1107
1137
 
1138
+ router.options('/oauth/par', corsPreflight)
1108
1139
  router.post(
1109
1140
  '/oauth/par',
1110
1141
  jsonHandler(async function (req, _res) {
1111
- const input = await validateRequestPayload(
1142
+ const input = await validateRequest(
1112
1143
  req,
1113
1144
  pushedAuthorizationRequestSchema,
1114
1145
  )
@@ -1124,14 +1155,18 @@ export class OAuthProvider extends OAuthVerifier {
1124
1155
  )
1125
1156
 
1126
1157
  // https://datatracker.ietf.org/doc/html/rfc9126#section-2.3
1127
- router.addRoute('*', '/oauth/par', (req, res) => {
1158
+ // > If the request did not use the POST method, the authorization server
1159
+ // > responds with an HTTP 405 (Method Not Allowed) status code.
1160
+ router.options('/oauth/par', corsPreflight)
1161
+ router.all('/oauth/par', (req, res) => {
1128
1162
  res.writeHead(405).end()
1129
1163
  })
1130
1164
 
1165
+ router.options('/oauth/token', corsPreflight)
1131
1166
  router.post(
1132
1167
  '/oauth/token',
1133
1168
  jsonHandler(async function (req, _res) {
1134
- const input = await validateRequestPayload(req, tokenRequestSchema)
1169
+ const input = await validateRequest(req, tokenRequestSchema)
1135
1170
 
1136
1171
  const dpopJkt = await server.checkDpopProof(
1137
1172
  req.headers['dpop'],
@@ -1143,10 +1178,11 @@ export class OAuthProvider extends OAuthVerifier {
1143
1178
  }),
1144
1179
  )
1145
1180
 
1181
+ router.options('/oauth/revoke', corsPreflight)
1146
1182
  router.post(
1147
1183
  '/oauth/revoke',
1148
1184
  jsonHandler(async function (req, res) {
1149
- const input = await validateRequestPayload(req, revokeSchema)
1185
+ const input = await validateRequest(req, revokeSchema)
1150
1186
 
1151
1187
  try {
1152
1188
  await server.revoke(input)
@@ -1156,6 +1192,7 @@ export class OAuthProvider extends OAuthVerifier {
1156
1192
  }),
1157
1193
  )
1158
1194
 
1195
+ router.options('/oauth/revoke', corsPreflight)
1159
1196
  router.get(
1160
1197
  '/oauth/revoke',
1161
1198
  navigationHandler(async function (req, res) {
@@ -1180,69 +1217,11 @@ export class OAuthProvider extends OAuthVerifier {
1180
1217
  router.post(
1181
1218
  '/oauth/introspect',
1182
1219
  jsonHandler(async function (req, _res) {
1183
- const input = await validateRequestPayload(req, introspectSchema)
1220
+ const input = await validateRequest(req, introspectSchema)
1184
1221
  return server.introspect(input)
1185
1222
  }),
1186
1223
  )
1187
1224
 
1188
- const userinfoBodySchema = z.object({
1189
- access_token: signedJwtSchema.optional(),
1190
- })
1191
-
1192
- router.addRoute(
1193
- ['GET', 'POST'],
1194
- '/oauth/userinfo',
1195
- acceptMiddleware(
1196
- async function (req, _res) {
1197
- const body =
1198
- req.method === 'POST'
1199
- ? await validateRequestPayload(req, userinfoBodySchema)
1200
- : null
1201
-
1202
- if (body?.access_token && req.headers['authorization']) {
1203
- throw new InvalidRequestError(
1204
- 'access token must be provided in either the authorization header or the request body',
1205
- )
1206
- }
1207
-
1208
- const auth = await server.authenticateRequest(
1209
- req.method!,
1210
- this.url,
1211
- body?.access_token // Allow credentials to be parsed from body.
1212
- ? {
1213
- authorization: `Bearer ${body.access_token}`,
1214
- dpop: undefined, // DPoP can only be used with headers
1215
- }
1216
- : req.headers,
1217
- {
1218
- scope: ['profile'],
1219
- },
1220
- )
1221
-
1222
- const tokenInfo: TokenInfo =
1223
- 'tokenInfo' in auth
1224
- ? (auth.tokenInfo as TokenInfo)
1225
- : await server.tokenManager.getTokenInfo(
1226
- auth.tokenType,
1227
- auth.tokenId,
1228
- )
1229
-
1230
- return server.userinfo(tokenInfo)
1231
- },
1232
- {
1233
- '': 'application/json',
1234
- 'application/json': jsonHandler(async function (_req, _res) {
1235
- return this.data
1236
- }),
1237
- 'application/jwt': jsonHandler(async function (_req, res) {
1238
- const jwt = await server.signUserinfo(this.data)
1239
- res.writeHead(200, { 'Content-Type': 'application/jwt' }).end(jwt)
1240
- return undefined
1241
- }),
1242
- },
1243
- ),
1244
- )
1245
-
1246
1225
  //- Private authorization endpoints
1247
1226
 
1248
1227
  router.use(authorizeAssetsMiddleware())
@@ -1250,6 +1229,8 @@ export class OAuthProvider extends OAuthVerifier {
1250
1229
  router.get(
1251
1230
  '/oauth/authorize',
1252
1231
  navigationHandler(async function (req, res) {
1232
+ validateFetchSite(req, res, ['cross-site', 'none'])
1233
+
1253
1234
  const query = Object.fromEntries(this.url.searchParams)
1254
1235
  const input = await authorizationRequestQuerySchema.parseAsync(query, {
1255
1236
  path: ['query'],
@@ -1281,13 +1262,15 @@ export class OAuthProvider extends OAuthVerifier {
1281
1262
  credentials: signInCredentialsSchema,
1282
1263
  })
1283
1264
 
1265
+ router.options('/oauth/authorize/sign-in', corsPreflight)
1284
1266
  router.post(
1285
1267
  '/oauth/authorize/sign-in',
1286
1268
  jsonHandler(async function (req, res) {
1287
1269
  validateFetchMode(req, res, ['same-origin'])
1270
+ validateFetchSite(req, res, ['same-origin'])
1288
1271
  validateSameOrigin(req, res, issuerOrigin)
1289
1272
 
1290
- const input = await validateRequestPayload(req, signInPayloadSchema)
1273
+ const input = await validateRequest(req, signInPayloadSchema)
1291
1274
 
1292
1275
  validateReferer(req, res, {
1293
1276
  origin: issuerOrigin,
@@ -1300,20 +1283,14 @@ export class OAuthProvider extends OAuthVerifier {
1300
1283
  csrfCookie(input.request_uri),
1301
1284
  )
1302
1285
 
1303
- const { deviceId } = await deviceManager.load(req, res)
1286
+ const { deviceId } = await deviceManager.load(req, res, true)
1304
1287
 
1305
- const { account, info } = await server.signIn(
1288
+ return server.signIn(
1306
1289
  deviceId,
1290
+ input.request_uri,
1291
+ input.client_id,
1307
1292
  input.credentials,
1308
1293
  )
1309
-
1310
- // Prevent fixation attacks
1311
- await deviceManager.rotate(req, res, deviceId)
1312
-
1313
- return {
1314
- account,
1315
- consentRequired: !info.authorizedClients.includes(input.client_id),
1316
- }
1317
1294
  }),
1318
1295
  )
1319
1296
 
@@ -1324,9 +1301,20 @@ export class OAuthProvider extends OAuthVerifier {
1324
1301
  account_sub: z.string(),
1325
1302
  })
1326
1303
 
1304
+ // Though this is a "no-cors" request, meaning that the browser will allow
1305
+ // any cross-origin request, with credentials, to be sent, the handler will
1306
+ // 1) validate the request origin,
1307
+ // 2) validate the CSRF token,
1308
+ // 3) validate the referer,
1309
+ // 4) validate the sec-fetch-site header,
1310
+ // 4) validate the sec-fetch-mode header,
1311
+ // 5) validate the sec-fetch-dest header (see navigationHandler).
1312
+ // And will error if any of these checks fail.
1327
1313
  router.get(
1328
1314
  '/oauth/authorize/accept',
1329
1315
  navigationHandler(async function (req, res) {
1316
+ validateFetchSite(req, res, ['same-origin'])
1317
+
1330
1318
  const query = Object.fromEntries(this.url.searchParams)
1331
1319
  const input = await acceptQuerySchema.parseAsync(query, {
1332
1320
  path: ['query'],
@@ -1367,9 +1355,20 @@ export class OAuthProvider extends OAuthVerifier {
1367
1355
  client_id: clientIdSchema,
1368
1356
  })
1369
1357
 
1358
+ // Though this is a "no-cors" request, meaning that the browser will allow
1359
+ // any cross-origin request, with credentials, to be sent, the handler will
1360
+ // 1) validate the request origin,
1361
+ // 2) validate the CSRF token,
1362
+ // 3) validate the referer,
1363
+ // 4) validate the sec-fetch-site header,
1364
+ // 4) validate the sec-fetch-mode header,
1365
+ // 5) validate the sec-fetch-dest header (see navigationHandler).
1366
+ // And will error if any of these checks fail.
1370
1367
  router.get(
1371
1368
  '/oauth/authorize/reject',
1372
1369
  navigationHandler(async function (req, res) {
1370
+ validateFetchSite(req, res, ['same-origin'])
1371
+
1373
1372
  const query = Object.fromEntries(this.url.searchParams)
1374
1373
  const input = await rejectQuerySchema.parseAsync(query, {
1375
1374
  path: ['query'],
@@ -1406,3 +1405,26 @@ export class OAuthProvider extends OAuthVerifier {
1406
1405
  return router
1407
1406
  }
1408
1407
  }
1408
+
1409
+ async function validateRequest<S extends z.ZodTypeAny>(
1410
+ req: IncomingMessage,
1411
+ schema: S,
1412
+ ): Promise<z.TypeOf<S>> {
1413
+ try {
1414
+ return await validateRequestPayload(req, schema)
1415
+ } catch (err) {
1416
+ if (err instanceof ZodError) {
1417
+ const issue = err.issues[0]
1418
+ if (issue?.path.length) {
1419
+ // "part" will typically be
1420
+ const [part, ...path] = issue.path
1421
+ throw new InvalidRequestError(
1422
+ `Validation of ${part}'s "${path.join('.')}" with error: ${issue.message}`,
1423
+ err,
1424
+ )
1425
+ }
1426
+ }
1427
+
1428
+ throw new InvalidRequestError('Input validation error', err)
1429
+ }
1430
+ }
@@ -36,8 +36,7 @@ export type OAuthVerifierOptions = Override<
36
36
  issuer: URL | string
37
37
 
38
38
  /**
39
- * The keyset used to sign tokens. Note that OIDC requires that at least one
40
- * RS256 key is present in the keyset. ATPROTO requires ES256.
39
+ * The keyset used to sign access tokens.
41
40
  */
42
41
  keyset: Keyset | Iterable<Key | undefined | null | false>
43
42
 
@@ -8,12 +8,18 @@ import { Account } from '../account/account.js'
8
8
  import { Client } from '../client/client.js'
9
9
  import { RequestUri } from '../request/request-uri.js'
10
10
 
11
+ export type ScopeDetail = {
12
+ scope: string
13
+ description?: string
14
+ }
15
+
11
16
  export type AuthorizationResultAuthorize = {
12
17
  issuer: string
13
18
  client: Client
14
19
  parameters: OAuthAuthenticationRequestParameters
15
20
  authorize: {
16
21
  uri: RequestUri
22
+ scopeDetails?: ScopeDetail[]
17
23
  sessions: readonly {
18
24
  account: Account
19
25
  info: DeviceAccountInfo
@@ -44,6 +50,7 @@ export type AuthorizeData = {
44
50
  requestUri: string
45
51
  csrfCookie: string
46
52
  loginHint?: string
53
+ scopeDetails?: ScopeDetail[]
47
54
  newSessionsRequireConsent: boolean
48
55
  sessions: Session[]
49
56
  }
@@ -59,6 +66,7 @@ export function buildAuthorizeData(
59
66
  csrfCookie: `csrf-${data.authorize.uri}`,
60
67
  loginHint: data.parameters.login_hint,
61
68
  newSessionsRequireConsent: data.parameters.prompt === 'consent',
69
+ scopeDetails: data.authorize.scopeDetails,
62
70
  sessions: data.authorize.sessions.map(
63
71
  (session): Session => ({
64
72
  account: session.account,
@@ -2,6 +2,7 @@ import { ClientId } from '../client/client-id.js'
2
2
  import {
3
3
  CLIENT_ASSERTION_MAX_AGE,
4
4
  DPOP_NONCE_MAX_AGE,
5
+ CODE_CHALLENGE_REPLAY_TIMEFRAME,
5
6
  JAR_MAX_AGE,
6
7
  } from '../constants.js'
7
8
  import { ReplayStore } from './replay-store.js'
@@ -35,4 +36,12 @@ export class ReplayManager {
35
36
  asTimeFrame(DPOP_NONCE_MAX_AGE),
36
37
  )
37
38
  }
39
+
40
+ async uniqueCodeChallenge(challenge: string): Promise<boolean> {
41
+ return this.replayStore.unique(
42
+ 'CodeChallenge',
43
+ challenge,
44
+ asTimeFrame(CODE_CHALLENGE_REPLAY_TIMEFRAME),
45
+ )
46
+ }
38
47
  }