@atproto/oauth-provider 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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