@atproto/oauth-provider 0.1.2 → 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 (136) hide show
  1. package/CHANGELOG.md +46 -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 +3 -3
  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 +91 -77
  15. package/dist/client/client-manager.js.map +1 -1
  16. package/dist/client/client.d.ts +2 -3
  17. package/dist/client/client.d.ts.map +1 -1
  18. package/dist/client/client.js +6 -12
  19. package/dist/client/client.js.map +1 -1
  20. package/dist/constants.d.ts +2 -0
  21. package/dist/constants.d.ts.map +1 -1
  22. package/dist/constants.js +3 -1
  23. package/dist/constants.js.map +1 -1
  24. package/dist/device/device-manager.d.ts +1 -1
  25. package/dist/device/device-manager.d.ts.map +1 -1
  26. package/dist/device/device-manager.js +2 -2
  27. package/dist/device/device-manager.js.map +1 -1
  28. package/dist/dpop/dpop-manager.d.ts +0 -1
  29. package/dist/dpop/dpop-manager.d.ts.map +1 -1
  30. package/dist/dpop/dpop-manager.js +1 -4
  31. package/dist/dpop/dpop-manager.js.map +1 -1
  32. package/dist/errors/invalid-authorization-details-error.d.ts +4 -3
  33. package/dist/errors/invalid-authorization-details-error.d.ts.map +1 -1
  34. package/dist/errors/invalid-authorization-details-error.js +4 -4
  35. package/dist/errors/invalid-authorization-details-error.js.map +1 -1
  36. package/dist/lib/http/parser.d.ts +13 -7
  37. package/dist/lib/http/parser.d.ts.map +1 -1
  38. package/dist/lib/http/parser.js +29 -9
  39. package/dist/lib/http/parser.js.map +1 -1
  40. package/dist/lib/http/request.d.ts +8 -5
  41. package/dist/lib/http/request.d.ts.map +1 -1
  42. package/dist/lib/http/request.js +24 -12
  43. package/dist/lib/http/request.js.map +1 -1
  44. package/dist/lib/http/stream.d.ts.map +1 -1
  45. package/dist/lib/http/stream.js +3 -2
  46. package/dist/lib/http/stream.js.map +1 -1
  47. package/dist/metadata/build-metadata.d.ts +0 -1
  48. package/dist/metadata/build-metadata.d.ts.map +1 -1
  49. package/dist/metadata/build-metadata.js +9 -49
  50. package/dist/metadata/build-metadata.js.map +1 -1
  51. package/dist/oauth-hooks.d.ts +3 -10
  52. package/dist/oauth-hooks.d.ts.map +1 -1
  53. package/dist/oauth-provider.d.ts +10 -15
  54. package/dist/oauth-provider.d.ts.map +1 -1
  55. package/dist/oauth-provider.js +176 -114
  56. package/dist/oauth-provider.js.map +1 -1
  57. package/dist/oauth-verifier.d.ts +1 -2
  58. package/dist/oauth-verifier.d.ts.map +1 -1
  59. package/dist/oauth-verifier.js.map +1 -1
  60. package/dist/output/build-authorize-data.d.ts +6 -0
  61. package/dist/output/build-authorize-data.d.ts.map +1 -1
  62. package/dist/output/build-authorize-data.js +1 -0
  63. package/dist/output/build-authorize-data.js.map +1 -1
  64. package/dist/replay/replay-manager.d.ts +1 -0
  65. package/dist/replay/replay-manager.d.ts.map +1 -1
  66. package/dist/replay/replay-manager.js +3 -0
  67. package/dist/replay/replay-manager.js.map +1 -1
  68. package/dist/replay/replay-store.d.ts +1 -1
  69. package/dist/request/request-info.d.ts +2 -0
  70. package/dist/request/request-info.d.ts.map +1 -1
  71. package/dist/request/request-manager.d.ts +3 -9
  72. package/dist/request/request-manager.d.ts.map +1 -1
  73. package/dist/request/request-manager.js +52 -77
  74. package/dist/request/request-manager.js.map +1 -1
  75. package/dist/request/types.d.ts +10 -10
  76. package/dist/signer/signed-token-payload.d.ts +88 -88
  77. package/dist/signer/signer.d.ts +24 -31
  78. package/dist/signer/signer.d.ts.map +1 -1
  79. package/dist/signer/signer.js +0 -40
  80. package/dist/signer/signer.js.map +1 -1
  81. package/dist/token/token-claims.d.ts +84 -84
  82. package/dist/token/token-manager.d.ts +1 -2
  83. package/dist/token/token-manager.d.ts.map +1 -1
  84. package/dist/token/token-manager.js +10 -37
  85. package/dist/token/token-manager.js.map +1 -1
  86. package/dist/token/types.d.ts +10 -10
  87. package/package.json +3 -3
  88. package/src/account/account.ts +11 -7
  89. package/src/assets/app/backend-data.ts +9 -2
  90. package/src/assets/app/components/accept-form.tsx +65 -51
  91. package/src/assets/app/components/client-name.tsx +24 -16
  92. package/src/assets/app/components/url-viewer.tsx +3 -3
  93. package/src/assets/app/views/accept-view.tsx +7 -4
  94. package/src/assets/app/views/authorize-view.tsx +2 -1
  95. package/src/assets/assets-middleware.ts +14 -2
  96. package/src/client/client-manager.ts +124 -120
  97. package/src/client/client.ts +5 -17
  98. package/src/constants.ts +3 -0
  99. package/src/device/device-manager.ts +7 -1
  100. package/src/dpop/dpop-manager.ts +1 -6
  101. package/src/errors/invalid-authorization-details-error.ts +9 -4
  102. package/src/lib/http/parser.ts +37 -13
  103. package/src/lib/http/request.ts +61 -15
  104. package/src/lib/http/stream.ts +5 -2
  105. package/src/metadata/build-metadata.ts +9 -56
  106. package/src/oauth-hooks.ts +3 -13
  107. package/src/oauth-provider.ts +187 -177
  108. package/src/oauth-verifier.ts +1 -2
  109. package/src/output/build-authorize-data.ts +8 -0
  110. package/src/replay/replay-manager.ts +9 -0
  111. package/src/replay/replay-store.ts +1 -1
  112. package/src/request/request-info.ts +2 -0
  113. package/src/request/request-manager.ts +81 -107
  114. package/src/signer/signer.ts +0 -63
  115. package/src/token/token-manager.ts +8 -41
  116. package/dist/oidc/claims.d.ts +0 -16
  117. package/dist/oidc/claims.d.ts.map +0 -1
  118. package/dist/oidc/claims.js +0 -29
  119. package/dist/oidc/claims.js.map +0 -1
  120. package/dist/oidc/userinfo.d.ts +0 -7
  121. package/dist/oidc/userinfo.d.ts.map +0 -1
  122. package/dist/oidc/userinfo.js +0 -3
  123. package/dist/oidc/userinfo.js.map +0 -1
  124. package/dist/parameters/claims-requested.d.ts +0 -3
  125. package/dist/parameters/claims-requested.d.ts.map +0 -1
  126. package/dist/parameters/claims-requested.js +0 -77
  127. package/dist/parameters/claims-requested.js.map +0 -1
  128. package/dist/parameters/oidc-payload.d.ts +0 -31
  129. package/dist/parameters/oidc-payload.d.ts.map +0 -1
  130. package/dist/parameters/oidc-payload.js +0 -25
  131. package/dist/parameters/oidc-payload.js.map +0 -1
  132. package/src/assets/app/components/client-identifier.tsx +0 -31
  133. package/src/oidc/claims.ts +0 -35
  134. package/src/oidc/userinfo.ts +0 -11
  135. package/src/parameters/claims-requested.ts +0 -106
  136. 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,
@@ -9,19 +9,17 @@ import {
9
9
  OAuthAuthorizationServerMetadata,
10
10
  OAuthClientIdentification,
11
11
  OAuthClientMetadata,
12
- OAuthEndpointName,
13
12
  OAuthTokenResponse,
14
13
  OAuthTokenType,
15
14
  atprotoLoopbackClientMetadata,
16
15
  oauthAuthenticationRequestParametersSchema,
17
16
  } from '@atproto/oauth-types'
18
17
  import { Redis, type RedisOptions } from 'ioredis'
19
- import { z } from 'zod'
18
+ import z, { ZodError } from 'zod'
20
19
 
21
20
  import { AccessTokenType } from './access-token/access-token-type.js'
22
21
  import { AccountManager } from './account/account-manager.js'
23
22
  import {
24
- AccountInfo,
25
23
  AccountStore,
26
24
  DeviceAccountInfo,
27
25
  SignInCredentials,
@@ -59,12 +57,13 @@ import {
59
57
  Middleware,
60
58
  Router,
61
59
  ServerResponse,
62
- acceptMiddleware,
63
60
  combineMiddlewares,
64
61
  setupCsrfToken,
65
62
  staticJsonHandler,
66
63
  validateCsrfToken,
64
+ validateFetchDest,
67
65
  validateFetchMode,
66
+ validateFetchSite,
68
67
  validateReferer,
69
68
  validateRequestPayload,
70
69
  validateSameOrigin,
@@ -75,7 +74,6 @@ import { Override } from './lib/util/type.js'
75
74
  import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js'
76
75
  import { OAuthHooks } from './oauth-hooks.js'
77
76
  import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js'
78
- import { Userinfo } from './oidc/userinfo.js'
79
77
  import { AuthorizationResultAuthorize } from './output/build-authorize-data.js'
80
78
  import {
81
79
  buildErrorPayload,
@@ -87,7 +85,6 @@ import {
87
85
  AuthorizationResultRedirect,
88
86
  sendAuthorizeRedirect,
89
87
  } from './output/send-authorize-redirect.js'
90
- import { oidcPayload } from './parameters/oidc-payload.js'
91
88
  import { ReplayStore, ifReplayStore } from './replay/replay-store.js'
92
89
  import { RequestInfo } from './request/request-info.js'
93
90
  import { RequestManager } from './request/request-manager.js'
@@ -104,7 +101,7 @@ import {
104
101
  } from './request/types.js'
105
102
  import { isTokenId } from './token/token-id.js'
106
103
  import { TokenManager } from './token/token-manager.js'
107
- import { TokenInfo, TokenStore, asTokenStore } from './token/token-store.js'
104
+ import { TokenStore, asTokenStore } from './token/token-store.js'
108
105
  import {
109
106
  CodeGrantRequest,
110
107
  Introspect,
@@ -147,9 +144,7 @@ export type OAuthProviderOptions = Override<
147
144
  {
148
145
  /**
149
146
  * Maximum age a device/account session can be before requiring
150
- * re-authentication. This can be overridden on a authorization request basis
151
- * using the `max_age` parameter and on a client basis using the
152
- * `default_max_age` client metadata.
147
+ * re-authentication.
153
148
  */
154
149
  authenticationMaxAge?: number
155
150
 
@@ -287,6 +282,7 @@ export class OAuthProvider extends OAuthVerifier {
287
282
 
288
283
  this.accountManager = new AccountManager(accountStore)
289
284
  this.clientManager = new ClientManager(
285
+ this.metadata,
290
286
  this.keyset,
291
287
  rest,
292
288
  clientStore || null,
@@ -327,26 +323,16 @@ export class OAuthProvider extends OAuthVerifier {
327
323
  return true
328
324
  }
329
325
 
330
- /** in seconds */
331
- const maxAge = parameters.max_age ?? client.metadata.default_max_age
332
-
333
- if (maxAge != null && maxAge < this.authenticationMaxAge) {
334
- return authAge >= maxAge
335
- } else {
336
- return authAge >= this.authenticationMaxAge
337
- }
326
+ return authAge >= this.authenticationMaxAge
338
327
  }
339
328
 
340
329
  protected async authenticateClient(
341
330
  client: Client,
342
- endpoint: OAuthEndpointName,
343
331
  credentials: OAuthClientIdentification,
344
332
  ): Promise<ClientAuth> {
345
- const { clientAuth, nonce } = await client.verifyCredentials(
346
- credentials,
347
- endpoint,
348
- { audience: this.issuer },
349
- )
333
+ const { clientAuth, nonce } = await client.verifyCredentials(credentials, {
334
+ audience: this.issuer,
335
+ })
350
336
 
351
337
  if (nonce != null) {
352
338
  const unique = await this.replayManager.uniqueAuth(nonce, client.id)
@@ -424,11 +410,7 @@ export class OAuthProvider extends OAuthVerifier {
424
410
  ) {
425
411
  try {
426
412
  const client = await this.clientManager.getClient(input.client_id)
427
- const clientAuth = await this.authenticateClient(
428
- client,
429
- 'pushed_authorization_request',
430
- input,
431
- )
413
+ const clientAuth = await this.authenticateClient(client, input)
432
414
 
433
415
  const { payload: parameters } =
434
416
  'request' in input // Handle JAR
@@ -559,15 +541,14 @@ export class OAuthProvider extends OAuthVerifier {
559
541
  throw new ConsentRequiredError(parameters)
560
542
  }
561
543
 
562
- const redirect = await this.requestManager.setAuthorized(
544
+ const code = await this.requestManager.setAuthorized(
563
545
  client,
564
546
  uri,
565
547
  deviceId,
566
548
  ssoSession.account,
567
- ssoSession.info,
568
549
  )
569
550
 
570
- return { issuer, client, parameters, redirect }
551
+ return { issuer, client, parameters, redirect: { code } }
571
552
  }
572
553
 
573
554
  // Automatic SSO when a did was provided
@@ -576,15 +557,14 @@ export class OAuthProvider extends OAuthVerifier {
576
557
  if (ssoSessions.length === 1) {
577
558
  const ssoSession = ssoSessions[0]!
578
559
  if (!ssoSession.loginRequired && !ssoSession.consentRequired) {
579
- const redirect = await this.requestManager.setAuthorized(
560
+ const code = await this.requestManager.setAuthorized(
580
561
  client,
581
562
  uri,
582
563
  deviceId,
583
564
  ssoSession.account,
584
- ssoSession.info,
585
565
  )
586
566
 
587
- return { issuer, client, parameters, redirect }
567
+ return { issuer, client, parameters, redirect: { code } }
588
568
  }
589
569
  }
590
570
  }
@@ -593,7 +573,20 @@ export class OAuthProvider extends OAuthVerifier {
593
573
  issuer,
594
574
  client,
595
575
  parameters,
596
- 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
+ },
597
590
  }
598
591
  } catch (err) {
599
592
  await this.deleteRequest(uri, parameters)
@@ -660,6 +653,9 @@ export class OAuthProvider extends OAuthVerifier {
660
653
  this.loginRequired(client, parameters, info),
661
654
  consentRequired:
662
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.
663
659
  !info.authorizedClients.includes(client.id),
664
660
 
665
661
  matchesHint: hint == null || matchesHint(account),
@@ -668,9 +664,33 @@ export class OAuthProvider extends OAuthVerifier {
668
664
 
669
665
  protected async signIn(
670
666
  deviceId: DeviceId,
667
+ uri: RequestUri,
668
+ clientId: ClientId,
671
669
  credentials: SignInCredentials,
672
- ): Promise<AccountInfo> {
673
- 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
+ }
674
694
  }
675
695
 
676
696
  protected async acceptRequest(
@@ -700,12 +720,11 @@ export class OAuthProvider extends OAuthVerifier {
700
720
  )
701
721
  }
702
722
 
703
- const redirect = await this.requestManager.setAuthorized(
723
+ const code = await this.requestManager.setAuthorized(
704
724
  client,
705
725
  uri,
706
726
  deviceId,
707
727
  account,
708
- info,
709
728
  )
710
729
 
711
730
  await this.accountManager.addAuthorizedClient(
@@ -715,7 +734,7 @@ export class OAuthProvider extends OAuthVerifier {
715
734
  clientAuth,
716
735
  )
717
736
 
718
- return { issuer, client, parameters, redirect }
737
+ return { issuer, client, parameters, redirect: { code } }
719
738
  } catch (err) {
720
739
  await this.deleteRequest(uri, parameters)
721
740
 
@@ -767,7 +786,7 @@ export class OAuthProvider extends OAuthVerifier {
767
786
  dpopJkt: null | string,
768
787
  ): Promise<OAuthTokenResponse> {
769
788
  const client = await this.clientManager.getClient(input.client_id)
770
- const clientAuth = await this.authenticateClient(client, 'token', input)
789
+ const clientAuth = await this.authenticateClient(client, input)
771
790
 
772
791
  if (!client.metadata.grant_types.includes(input.grant_type)) {
773
792
  throw new InvalidGrantError(
@@ -802,6 +821,32 @@ export class OAuthProvider extends OAuthVerifier {
802
821
  input.code,
803
822
  )
804
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
+
805
850
  const { account, info } = await this.accountManager.get(deviceId, sub)
806
851
 
807
852
  return await this.tokenManager.create(
@@ -851,11 +896,7 @@ export class OAuthProvider extends OAuthVerifier {
851
896
  input: Introspect,
852
897
  ): Promise<IntrospectionResponse> {
853
898
  const client = await this.clientManager.getClient(input.client_id)
854
- const clientAuth = await this.authenticateClient(
855
- client,
856
- 'introspection',
857
- input,
858
- )
899
+ const clientAuth = await this.authenticateClient(client, input)
859
900
 
860
901
  // RFC7662 states the following:
861
902
  //
@@ -903,31 +944,6 @@ export class OAuthProvider extends OAuthVerifier {
903
944
  }
904
945
  }
905
946
 
906
- /**
907
- * @see {@link https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.2 Successful UserInfo Response}
908
- */
909
- protected async userinfo({ data, account }: TokenInfo): Promise<Userinfo> {
910
- return {
911
- ...oidcPayload(data.parameters, account),
912
-
913
- sub: account.sub,
914
-
915
- client_id: data.clientId,
916
- username: account.preferred_username,
917
- }
918
- }
919
-
920
- protected async signUserinfo(userinfo: Userinfo): Promise<SignedJwt> {
921
- const client = await this.clientManager.getClient(userinfo.client_id)
922
- return this.signer.sign(
923
- {
924
- alg: client.metadata.userinfo_signed_response_alg,
925
- typ: 'JWT',
926
- },
927
- userinfo,
928
- )
929
- }
930
-
931
947
  protected override async authenticateToken(
932
948
  tokenType: OAuthTokenType,
933
949
  token: AccessToken,
@@ -991,6 +1007,8 @@ export class OAuthProvider extends OAuthVerifier {
991
1007
  combineMiddlewares([
992
1008
  function (req, res, next) {
993
1009
  res.setHeader('Access-Control-Allow-Origin', '*')
1010
+ res.setHeader('Access-Control-Allow-Headers', '*')
1011
+
994
1012
  res.setHeader('Cache-Control', 'max-age=300')
995
1013
  next()
996
1014
  },
@@ -1007,6 +1025,7 @@ export class OAuthProvider extends OAuthVerifier {
1007
1025
  ): Handler<T, TReq, TRes> =>
1008
1026
  async function (req, res) {
1009
1027
  res.setHeader('Access-Control-Allow-Origin', '*')
1028
+ res.setHeader('Access-Control-Allow-Headers', '*')
1010
1029
 
1011
1030
  // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1
1012
1031
  res.setHeader('Cache-Control', 'no-store')
@@ -1049,11 +1068,15 @@ export class OAuthProvider extends OAuthVerifier {
1049
1068
  handler: (this: T, req: TReq, res: TRes) => void | Promise<void>,
1050
1069
  ): Handler<T, TReq, TRes> =>
1051
1070
  async function (req, res) {
1071
+ res.setHeader('Access-Control-Allow-Origin', '*')
1072
+ res.setHeader('Access-Control-Allow-Headers', '*')
1073
+
1052
1074
  res.setHeader('Cache-Control', 'no-store')
1053
1075
  res.setHeader('Pragma', 'no-cache')
1054
1076
 
1055
1077
  try {
1056
1078
  validateFetchMode(req, res, ['navigate'])
1079
+ validateFetchDest(req, res, ['document'])
1057
1080
  validateSameOrigin(req, res, issuerOrigin)
1058
1081
 
1059
1082
  await handler.call(this, req, res)
@@ -1078,49 +1101,45 @@ export class OAuthProvider extends OAuthVerifier {
1078
1101
 
1079
1102
  //- Public OAuth endpoints
1080
1103
 
1081
- /*
1082
- * Although OpenID compatibility is not required to implement the Atproto
1083
- * OAuth2 specification, we do support OIDC discovery in this
1084
- * implementation as we believe this may:
1085
- * 1) Make the implementation of Atproto clients easier (since lots of
1086
- * libraries support OIDC discovery)
1087
- * 2) Allow self hosted PDS' to not implement authentication themselves
1088
- * but rely on a trusted Atproto actor to act as their OIDC providers.
1089
- * By supporting OIDC in the current implementation, Bluesky's
1090
- * Authorization Server server can be used as an OIDC provider for
1091
- * these users.
1092
- */
1093
- router.get('/.well-known/openid-configuration', staticJson(server.metadata))
1094
-
1095
1104
  router.get(
1096
1105
  '/.well-known/oauth-authorization-server',
1097
1106
  staticJson(server.metadata),
1098
1107
  )
1099
1108
 
1100
1109
  // CORS preflight
1101
- router.options<{
1102
- endpoint: 'jwks' | 'par' | 'token' | 'revoke' | 'introspect' | 'userinfo'
1103
- }>(
1104
- /^\/oauth\/(?<endpoint>jwks|par|token|revoke|introspect|userinfo)$/,
1105
- function (req, res, _next) {
1106
- res
1107
- .writeHead(204, {
1108
- 'Access-Control-Allow-Origin': req.headers['origin'] || '*',
1109
- 'Access-Control-Allow-Methods':
1110
- this.params.endpoint === 'jwks' ? 'GET' : 'POST',
1111
- 'Access-Control-Allow-Headers': 'Content-Type,Authorization,DPoP',
1112
- 'Access-Control-Max-Age': '86400', // 1 day
1113
- })
1114
- .end()
1115
- },
1116
- )
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
+ }
1117
1135
 
1118
1136
  router.get('/oauth/jwks', staticJson(server.jwks))
1119
1137
 
1138
+ router.options('/oauth/par', corsPreflight)
1120
1139
  router.post(
1121
1140
  '/oauth/par',
1122
1141
  jsonHandler(async function (req, _res) {
1123
- const input = await validateRequestPayload(
1142
+ const input = await validateRequest(
1124
1143
  req,
1125
1144
  pushedAuthorizationRequestSchema,
1126
1145
  )
@@ -1136,14 +1155,18 @@ export class OAuthProvider extends OAuthVerifier {
1136
1155
  )
1137
1156
 
1138
1157
  // https://datatracker.ietf.org/doc/html/rfc9126#section-2.3
1139
- 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) => {
1140
1162
  res.writeHead(405).end()
1141
1163
  })
1142
1164
 
1165
+ router.options('/oauth/token', corsPreflight)
1143
1166
  router.post(
1144
1167
  '/oauth/token',
1145
1168
  jsonHandler(async function (req, _res) {
1146
- const input = await validateRequestPayload(req, tokenRequestSchema)
1169
+ const input = await validateRequest(req, tokenRequestSchema)
1147
1170
 
1148
1171
  const dpopJkt = await server.checkDpopProof(
1149
1172
  req.headers['dpop'],
@@ -1155,10 +1178,11 @@ export class OAuthProvider extends OAuthVerifier {
1155
1178
  }),
1156
1179
  )
1157
1180
 
1181
+ router.options('/oauth/revoke', corsPreflight)
1158
1182
  router.post(
1159
1183
  '/oauth/revoke',
1160
1184
  jsonHandler(async function (req, res) {
1161
- const input = await validateRequestPayload(req, revokeSchema)
1185
+ const input = await validateRequest(req, revokeSchema)
1162
1186
 
1163
1187
  try {
1164
1188
  await server.revoke(input)
@@ -1168,6 +1192,7 @@ export class OAuthProvider extends OAuthVerifier {
1168
1192
  }),
1169
1193
  )
1170
1194
 
1195
+ router.options('/oauth/revoke', corsPreflight)
1171
1196
  router.get(
1172
1197
  '/oauth/revoke',
1173
1198
  navigationHandler(async function (req, res) {
@@ -1192,69 +1217,11 @@ export class OAuthProvider extends OAuthVerifier {
1192
1217
  router.post(
1193
1218
  '/oauth/introspect',
1194
1219
  jsonHandler(async function (req, _res) {
1195
- const input = await validateRequestPayload(req, introspectSchema)
1220
+ const input = await validateRequest(req, introspectSchema)
1196
1221
  return server.introspect(input)
1197
1222
  }),
1198
1223
  )
1199
1224
 
1200
- const userinfoBodySchema = z.object({
1201
- access_token: signedJwtSchema.optional(),
1202
- })
1203
-
1204
- router.addRoute(
1205
- ['GET', 'POST'],
1206
- '/oauth/userinfo',
1207
- acceptMiddleware(
1208
- async function (req, _res) {
1209
- const body =
1210
- req.method === 'POST'
1211
- ? await validateRequestPayload(req, userinfoBodySchema)
1212
- : null
1213
-
1214
- if (body?.access_token && req.headers['authorization']) {
1215
- throw new InvalidRequestError(
1216
- 'access token must be provided in either the authorization header or the request body',
1217
- )
1218
- }
1219
-
1220
- const auth = await server.authenticateRequest(
1221
- req.method!,
1222
- this.url,
1223
- body?.access_token // Allow credentials to be parsed from body.
1224
- ? {
1225
- authorization: `Bearer ${body.access_token}`,
1226
- dpop: undefined, // DPoP can only be used with headers
1227
- }
1228
- : req.headers,
1229
- {
1230
- scope: ['profile'],
1231
- },
1232
- )
1233
-
1234
- const tokenInfo: TokenInfo =
1235
- 'tokenInfo' in auth
1236
- ? (auth.tokenInfo as TokenInfo)
1237
- : await server.tokenManager.getTokenInfo(
1238
- auth.tokenType,
1239
- auth.tokenId,
1240
- )
1241
-
1242
- return server.userinfo(tokenInfo)
1243
- },
1244
- {
1245
- '': 'application/json',
1246
- 'application/json': jsonHandler(async function (_req, _res) {
1247
- return this.data
1248
- }),
1249
- 'application/jwt': jsonHandler(async function (_req, res) {
1250
- const jwt = await server.signUserinfo(this.data)
1251
- res.writeHead(200, { 'Content-Type': 'application/jwt' }).end(jwt)
1252
- return undefined
1253
- }),
1254
- },
1255
- ),
1256
- )
1257
-
1258
1225
  //- Private authorization endpoints
1259
1226
 
1260
1227
  router.use(authorizeAssetsMiddleware())
@@ -1262,6 +1229,8 @@ export class OAuthProvider extends OAuthVerifier {
1262
1229
  router.get(
1263
1230
  '/oauth/authorize',
1264
1231
  navigationHandler(async function (req, res) {
1232
+ validateFetchSite(req, res, ['cross-site', 'none'])
1233
+
1265
1234
  const query = Object.fromEntries(this.url.searchParams)
1266
1235
  const input = await authorizationRequestQuerySchema.parseAsync(query, {
1267
1236
  path: ['query'],
@@ -1293,13 +1262,15 @@ export class OAuthProvider extends OAuthVerifier {
1293
1262
  credentials: signInCredentialsSchema,
1294
1263
  })
1295
1264
 
1265
+ router.options('/oauth/authorize/sign-in', corsPreflight)
1296
1266
  router.post(
1297
1267
  '/oauth/authorize/sign-in',
1298
1268
  jsonHandler(async function (req, res) {
1299
1269
  validateFetchMode(req, res, ['same-origin'])
1270
+ validateFetchSite(req, res, ['same-origin'])
1300
1271
  validateSameOrigin(req, res, issuerOrigin)
1301
1272
 
1302
- const input = await validateRequestPayload(req, signInPayloadSchema)
1273
+ const input = await validateRequest(req, signInPayloadSchema)
1303
1274
 
1304
1275
  validateReferer(req, res, {
1305
1276
  origin: issuerOrigin,
@@ -1312,20 +1283,14 @@ export class OAuthProvider extends OAuthVerifier {
1312
1283
  csrfCookie(input.request_uri),
1313
1284
  )
1314
1285
 
1315
- const { deviceId } = await deviceManager.load(req, res)
1286
+ const { deviceId } = await deviceManager.load(req, res, true)
1316
1287
 
1317
- const { account, info } = await server.signIn(
1288
+ return server.signIn(
1318
1289
  deviceId,
1290
+ input.request_uri,
1291
+ input.client_id,
1319
1292
  input.credentials,
1320
1293
  )
1321
-
1322
- // Prevent fixation attacks
1323
- await deviceManager.rotate(req, res, deviceId)
1324
-
1325
- return {
1326
- account,
1327
- consentRequired: !info.authorizedClients.includes(input.client_id),
1328
- }
1329
1294
  }),
1330
1295
  )
1331
1296
 
@@ -1336,9 +1301,20 @@ export class OAuthProvider extends OAuthVerifier {
1336
1301
  account_sub: z.string(),
1337
1302
  })
1338
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.
1339
1313
  router.get(
1340
1314
  '/oauth/authorize/accept',
1341
1315
  navigationHandler(async function (req, res) {
1316
+ validateFetchSite(req, res, ['same-origin'])
1317
+
1342
1318
  const query = Object.fromEntries(this.url.searchParams)
1343
1319
  const input = await acceptQuerySchema.parseAsync(query, {
1344
1320
  path: ['query'],
@@ -1379,9 +1355,20 @@ export class OAuthProvider extends OAuthVerifier {
1379
1355
  client_id: clientIdSchema,
1380
1356
  })
1381
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.
1382
1367
  router.get(
1383
1368
  '/oauth/authorize/reject',
1384
1369
  navigationHandler(async function (req, res) {
1370
+ validateFetchSite(req, res, ['same-origin'])
1371
+
1385
1372
  const query = Object.fromEntries(this.url.searchParams)
1386
1373
  const input = await rejectQuerySchema.parseAsync(query, {
1387
1374
  path: ['query'],
@@ -1418,3 +1405,26 @@ export class OAuthProvider extends OAuthVerifier {
1418
1405
  return router
1419
1406
  }
1420
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