@atproto/oauth-provider 0.1.3 → 0.2.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 (121) hide show
  1. package/CHANGELOG.md +29 -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/components/url-viewer.tsx +3 -3
  81. package/src/assets/app/views/accept-view.tsx +7 -4
  82. package/src/assets/app/views/authorize-view.tsx +2 -1
  83. package/src/assets/assets-middleware.ts +14 -2
  84. package/src/client/client-manager.ts +78 -60
  85. package/src/client/client.ts +1 -4
  86. package/src/constants.ts +3 -0
  87. package/src/device/device-manager.ts +7 -1
  88. package/src/errors/invalid-authorization-details-error.ts +9 -4
  89. package/src/lib/http/request.ts +61 -15
  90. package/src/metadata/build-metadata.ts +9 -42
  91. package/src/oauth-hooks.ts +3 -13
  92. package/src/oauth-provider.ts +181 -159
  93. package/src/oauth-verifier.ts +1 -2
  94. package/src/output/build-authorize-data.ts +8 -0
  95. package/src/replay/replay-manager.ts +9 -0
  96. package/src/replay/replay-store.ts +1 -1
  97. package/src/request/request-info.ts +2 -0
  98. package/src/request/request-manager.ts +81 -107
  99. package/src/signer/signer.ts +0 -63
  100. package/src/token/token-manager.ts +8 -41
  101. package/dist/oidc/claims.d.ts +0 -16
  102. package/dist/oidc/claims.d.ts.map +0 -1
  103. package/dist/oidc/claims.js +0 -29
  104. package/dist/oidc/claims.js.map +0 -1
  105. package/dist/oidc/userinfo.d.ts +0 -7
  106. package/dist/oidc/userinfo.d.ts.map +0 -1
  107. package/dist/oidc/userinfo.js +0 -3
  108. package/dist/oidc/userinfo.js.map +0 -1
  109. package/dist/parameters/claims-requested.d.ts +0 -3
  110. package/dist/parameters/claims-requested.d.ts.map +0 -1
  111. package/dist/parameters/claims-requested.js +0 -77
  112. package/dist/parameters/claims-requested.js.map +0 -1
  113. package/dist/parameters/oidc-payload.d.ts +0 -31
  114. package/dist/parameters/oidc-payload.d.ts.map +0 -1
  115. package/dist/parameters/oidc-payload.js +0 -25
  116. package/dist/parameters/oidc-payload.js.map +0 -1
  117. package/src/assets/app/components/client-identifier.tsx +0 -31
  118. package/src/oidc/claims.ts +0 -35
  119. package/src/oidc/userinfo.ts +0 -11
  120. package/src/parameters/claims-requested.ts +0 -106
  121. 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
  }