@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,22 +1,23 @@
1
1
  import { OAuthClientMetadata } from '@atproto/oauth-types'
2
2
  import { FormEvent } from 'react'
3
3
 
4
- import { Account } from '../backend-data'
4
+ import { Account, ScopeDetail } from '../backend-data'
5
5
  import { Override } from '../lib/util'
6
6
  import { AccountIdentifier } from './account-identifier'
7
7
  import { Button } from './button'
8
- import { ClientIdentifier } from './client-identifier'
9
8
  import { ClientName } from './client-name'
10
9
  import { FormCard, FormCardProps } from './form-card'
11
- import { Fieldset } from './fieldset'
12
10
 
13
11
  export type AcceptFormProps = Override<
14
12
  FormCardProps,
15
13
  {
16
- account: Account
17
14
  clientId: string
18
15
  clientMetadata: OAuthClientMetadata
19
16
  clientTrusted: boolean
17
+
18
+ account: Account
19
+ scopeDetails?: ScopeDetail[]
20
+
20
21
  onAccept: () => void
21
22
  acceptLabel?: string
22
23
 
@@ -29,10 +30,13 @@ export type AcceptFormProps = Override<
29
30
  >
30
31
 
31
32
  export function AcceptForm({
32
- account,
33
33
  clientId,
34
34
  clientMetadata,
35
35
  clientTrusted,
36
+
37
+ account,
38
+ scopeDetails,
39
+
36
40
  onAccept,
37
41
  acceptLabel = 'Accept',
38
42
  onReject,
@@ -62,54 +66,64 @@ export function AcceptForm({
62
66
  }
63
67
  {...props}
64
68
  >
65
- <Fieldset
66
- title={
67
- <ClientName clientId={clientId} clientMetadata={clientMetadata} />
68
- }
69
- >
70
- {clientTrusted && clientMetadata.logo_uri && (
71
- <div key="logo" className="flex items-center justify-center">
72
- <img
73
- crossOrigin="anonymous"
74
- src={clientMetadata.logo_uri}
75
- alt={clientMetadata.client_name}
76
- className="w-16 h-16 rounded-full"
77
- />
78
- </div>
79
- )}
69
+ {clientTrusted && clientMetadata.logo_uri && (
70
+ <div key="logo" className="flex items-center justify-center">
71
+ <img
72
+ crossOrigin="anonymous"
73
+ src={clientMetadata.logo_uri}
74
+ alt={clientMetadata.client_name}
75
+ className="w-16 h-16 rounded-full"
76
+ />
77
+ </div>
78
+ )}
79
+ <p>
80
+ <ClientName clientId={clientId} clientMetadata={clientMetadata} /> is
81
+ asking for permission to access your account (
82
+ <AccountIdentifier account={account} />
83
+ ).
84
+ </p>
80
85
 
81
- <p>
82
- <ClientIdentifier
83
- clientId={clientId}
84
- clientMetadata={clientMetadata}
85
- />{' '}
86
- is asking for permission to access your{' '}
87
- <AccountIdentifier account={account} /> account.
88
- </p>
86
+ <p>
87
+ By clicking <b>{acceptLabel}</b>, you allow this application to perform
88
+ the following actions in accordance to their{' '}
89
+ <a
90
+ href={clientMetadata.tos_uri}
91
+ rel="nofollow noopener"
92
+ target="_blank"
93
+ className="text-brand underline"
94
+ >
95
+ terms of service
96
+ </a>
97
+ {' and '}
98
+ <a
99
+ href={clientMetadata.policy_uri}
100
+ rel="nofollow noopener"
101
+ target="_blank"
102
+ className="text-brand underline"
103
+ >
104
+ privacy policy
105
+ </a>
106
+ :
107
+ </p>
89
108
 
90
- <p>
91
- By clicking <b>{acceptLabel}</b>, you allow this application to access
92
- your information in accordance to their{' '}
93
- <a
94
- href={clientMetadata.tos_uri}
95
- rel="nofollow noopener"
96
- target="_blank"
97
- className="text-brand underline"
98
- >
99
- terms of service
100
- </a>
101
- {' and '}
102
- <a
103
- href={clientMetadata.policy_uri}
104
- rel="nofollow noopener"
105
- target="_blank"
106
- className="text-brand underline"
107
- >
108
- privacy policy
109
- </a>
110
- .
111
- </p>
112
- </Fieldset>
109
+ {scopeDetails?.length ? (
110
+ <ul className="list-disc list-inside">
111
+ {scopeDetails.map(
112
+ ({ scope, description = getScopeDescription(scope) }) => (
113
+ <li key={scope}>{description}</li>
114
+ ),
115
+ )}
116
+ </ul>
117
+ ) : null}
113
118
  </FormCard>
114
119
  )
115
120
  }
121
+
122
+ function getScopeDescription(scope: string): string {
123
+ switch (scope) {
124
+ case 'atproto':
125
+ return 'Uniquely identify you'
126
+ default:
127
+ return scope
128
+ }
129
+ }
@@ -1,30 +1,38 @@
1
- import { OAuthClientMetadata } from '@atproto/oauth-types'
1
+ import {
2
+ isOAuthClientIdDiscoverable,
3
+ isOAuthClientIdLoopback,
4
+ OAuthClientMetadata,
5
+ } from '@atproto/oauth-types'
2
6
  import { HTMLAttributes } from 'react'
3
7
 
4
- import { ClientIdentifier } from './client-identifier'
8
+ import { UrlViewer } from './url-viewer'
5
9
 
6
10
  export type ClientNameProps = {
7
11
  clientId: string
8
12
  clientMetadata: OAuthClientMetadata
9
- as?: keyof JSX.IntrinsicElements
10
- }
13
+ } & HTMLAttributes<Element>
11
14
 
12
15
  export function ClientName({
13
16
  clientId,
14
17
  clientMetadata,
15
- as: As = 'span',
16
18
  ...attrs
17
- }: ClientNameProps & HTMLAttributes<Element>) {
18
- if (clientMetadata.client_name) {
19
- return <As {...attrs}>{clientMetadata.client_name}</As>
19
+ }: ClientNameProps) {
20
+ if (isOAuthClientIdLoopback(clientId)) {
21
+ return <span {...attrs}>An application on your device</span>
22
+ }
23
+
24
+ if (isOAuthClientIdDiscoverable(clientId)) {
25
+ if (clientMetadata.client_name) {
26
+ return (
27
+ <span {...attrs}>
28
+ {clientMetadata.client_name} (
29
+ <UrlViewer url={clientId} path />)
30
+ </span>
31
+ )
32
+ }
33
+
34
+ return <UrlViewer {...attrs} url={clientId} path />
20
35
  }
21
36
 
22
- return (
23
- <ClientIdentifier
24
- clientId={clientId}
25
- clientMetadata={clientMetadata}
26
- as={As}
27
- {...attrs}
28
- />
29
- )
37
+ return <span {...attrs}>{clientMetadata.client_name || clientId}</span>
30
38
  }
@@ -1,4 +1,4 @@
1
- import { HTMLAttributes, useMemo } from 'react'
1
+ import { Component, HTMLAttributes, useMemo } from 'react'
2
2
 
3
3
  export type UrlPartRenderingOptions = {
4
4
  faded?: boolean
@@ -28,7 +28,7 @@ export function UrlViewer({
28
28
  const urlObj = useMemo(() => new URL(url), [url])
29
29
 
30
30
  return (
31
- <As {...attrs}>
31
+ <Component as={As} {...attrs}>
32
32
  {proto && (
33
33
  <UrlPartViewer
34
34
  value={`${urlObj.protocol}//`}
@@ -56,7 +56,7 @@ export function UrlViewer({
56
56
  {hash && (
57
57
  <UrlPartViewer value={urlObj.hash} {...(hash === true ? null : hash)} />
58
58
  )}
59
- </As>
59
+ </Component>
60
60
  )
61
61
  }
62
62
 
@@ -1,6 +1,6 @@
1
1
  import { OAuthClientMetadata } from '@atproto/oauth-types'
2
2
 
3
- import { Session } from '../backend-data'
3
+ import { Account, ScopeDetail } from '../backend-data'
4
4
  import { AcceptForm } from '../components/accept-form'
5
5
  import { LayoutTitlePage } from '../components/layout-title-page'
6
6
 
@@ -8,7 +8,9 @@ export type AcceptViewProps = {
8
8
  clientId: string
9
9
  clientMetadata: OAuthClientMetadata
10
10
  clientTrusted: boolean
11
- session: Session
11
+
12
+ account: Account
13
+ scopeDetails?: ScopeDetail[]
12
14
 
13
15
  onAccept: () => void
14
16
  onReject: () => void
@@ -19,12 +21,12 @@ export function AcceptView({
19
21
  clientId,
20
22
  clientMetadata,
21
23
  clientTrusted,
22
- session,
24
+ account,
25
+ scopeDetails,
23
26
  onAccept,
24
27
  onReject,
25
28
  onBack,
26
29
  }: AcceptViewProps) {
27
- const { account } = session
28
30
  return (
29
31
  <LayoutTitlePage
30
32
  title="Authorize"
@@ -43,6 +45,7 @@ export function AcceptView({
43
45
  clientMetadata={clientMetadata}
44
46
  clientTrusted={clientTrusted}
45
47
  account={account}
48
+ scopeDetails={scopeDetails}
46
49
  onBack={onBack}
47
50
  onAccept={onAccept}
48
51
  onReject={onReject}
@@ -79,10 +79,11 @@ export function AuthorizeView({
79
79
  if (view === 'accept' && session) {
80
80
  return (
81
81
  <AcceptView
82
- session={session}
83
82
  clientId={authorizeData.clientId}
84
83
  clientMetadata={authorizeData.clientMetadata}
85
84
  clientTrusted={authorizeData.clientTrusted}
85
+ account={session.account}
86
+ scopeDetails={authorizeData.scopeDetails}
86
87
  onAccept={() => doAccept(session.account)}
87
88
  onReject={doReject}
88
89
  onBack={
@@ -1,8 +1,13 @@
1
- import { writeStream } from '../lib/http/index.js'
1
+ import {
2
+ Middleware,
3
+ validateFetchDest,
4
+ validateFetchSite,
5
+ writeStream,
6
+ } from '../lib/http/index.js'
2
7
 
3
8
  import { ASSETS_URL_PREFIX, getAsset } from './index.js'
4
9
 
5
- export function authorizeAssetsMiddleware() {
10
+ export function authorizeAssetsMiddleware(): Middleware {
6
11
  return async function assetsMiddleware(req, res, next): Promise<void> {
7
12
  if (req.method !== 'GET' && req.method !== 'HEAD') return next()
8
13
  if (!req.url?.startsWith(ASSETS_URL_PREFIX)) return next()
@@ -17,6 +22,13 @@ export function authorizeAssetsMiddleware() {
17
22
  const asset = await getAsset(filename).catch(() => null)
18
23
  if (!asset) return next()
19
24
 
25
+ try {
26
+ validateFetchSite(req, res, ['same-origin'])
27
+ validateFetchDest(req, res, ['style', 'script'])
28
+ } catch (err) {
29
+ return next(err)
30
+ }
31
+
20
32
  if (req.headers['if-none-match'] === asset.sha256) {
21
33
  return void res.writeHead(304).end()
22
34
  }
@@ -17,7 +17,7 @@ import {
17
17
  isLoopbackUrl,
18
18
  isOAuthClientIdDiscoverable,
19
19
  isOAuthClientIdLoopback,
20
- OAUTH_AUTHENTICATED_ENDPOINT_NAMES,
20
+ OAuthAuthorizationServerMetadata,
21
21
  OAuthClientIdDiscoverable,
22
22
  OAuthClientIdLoopback,
23
23
  OAuthClientMetadata,
@@ -56,9 +56,10 @@ export type LoopbackMetadataGetter = (
56
56
 
57
57
  export class ClientManager {
58
58
  protected readonly jwks: CachedGetter<string, Jwks>
59
- protected readonly metadata: CachedGetter<string, OAuthClientMetadata>
59
+ protected readonly metadataGetter: CachedGetter<string, OAuthClientMetadata>
60
60
 
61
61
  constructor(
62
+ protected readonly serverMetadata: OAuthAuthorizationServerMetadata,
62
63
  protected readonly keyset: Keyset,
63
64
  protected readonly hooks: OAuthHooks,
64
65
  protected readonly store: ClientStore | null,
@@ -77,7 +78,7 @@ export class ClientManager {
77
78
  return jwks
78
79
  }, clientJwksCache)
79
80
 
80
- this.metadata = new CachedGetter(async (uri, options) => {
81
+ this.metadataGetter = new CachedGetter(async (uri, options) => {
81
82
  const metadata = await fetch(buildJsonGetRequest(uri, options)).then(
82
83
  fetchMetadataHandler,
83
84
  )
@@ -160,7 +161,7 @@ export class ClientManager {
160
161
  ): Promise<OAuthClientMetadata> {
161
162
  const metadataUrl = parseDiscoverableClientId(clientId)
162
163
 
163
- const metadata = await this.metadata.get(metadataUrl.href)
164
+ const metadata = await this.metadataGetter.get(metadataUrl.href)
164
165
 
165
166
  // Note: we do *not* re-validate the metadata here, as the metadata is
166
167
  // validated within the getter. This is to avoid double validation.
@@ -196,6 +197,18 @@ export class ClientManager {
196
197
  )
197
198
  }
198
199
 
200
+ // Known OIDC specific parameters
201
+ for (const k of [
202
+ 'default_max_age',
203
+ 'userinfo_signed_response_alg',
204
+ 'id_token_signed_response_alg',
205
+ 'userinfo_encrypted_response_alg',
206
+ ] as const) {
207
+ if (metadata[k] != null) {
208
+ throw new InvalidClientMetadataError(`Unsupported "${k}" parameter`)
209
+ }
210
+ }
211
+
199
212
  const clientUriUrl = metadata.client_uri
200
213
  ? new URL(metadata.client_uri)
201
214
  : null
@@ -205,13 +218,27 @@ export class ClientManager {
205
218
  throw new InvalidClientMetadataError('client_uri must be a valid URL')
206
219
  }
207
220
 
208
- const scopes = metadata.scope?.split(' ')
209
- if (
210
- metadata.grant_types.includes('refresh_token') !==
211
- (scopes?.includes('offline_access') ?? false)
212
- ) {
221
+ const scopes = metadata.scope?.split(' ').filter(Boolean)
222
+
223
+ const dupScope = scopes?.find(isDuplicate)
224
+ if (dupScope) {
225
+ throw new InvalidClientMetadataError(`Duplicate scope "${dupScope}"`)
226
+ }
227
+
228
+ if (scopes) {
229
+ for (const scope of scopes) {
230
+ // Note, once we have dynamic scopes, this check will need to be
231
+ // updated to check against the server's supported scopes.
232
+ if (!this.serverMetadata.scopes_supported?.includes(scope)) {
233
+ throw new InvalidClientMetadataError(`Unsupported scope "${scope}"`)
234
+ }
235
+ }
236
+ }
237
+
238
+ const dupGrantType = metadata.grant_types.find(isDuplicate)
239
+ if (dupGrantType) {
213
240
  throw new InvalidClientMetadataError(
214
- 'Grant type "refresh_token" requires scope "offline_access" (and vice versa)',
241
+ `Duplicate grant type "${dupGrantType}"`,
215
242
  )
216
243
  }
217
244
 
@@ -219,8 +246,8 @@ export class ClientManager {
219
246
  switch (grantType) {
220
247
  case 'authorization_code':
221
248
  case 'refresh_token':
222
- case 'implicit': // Required by OIDC (for id_token)
223
249
  continue
250
+ case 'implicit':
224
251
  case 'password':
225
252
  throw new InvalidClientMetadataError(
226
253
  `Grant type "${grantType}" is not allowed`,
@@ -242,78 +269,43 @@ export class ClientManager {
242
269
  )
243
270
  }
244
271
 
245
- if (
246
- metadata.userinfo_signed_response_alg &&
247
- !this.keyset.signAlgorithms.includes(
248
- metadata.userinfo_signed_response_alg,
249
- )
250
- ) {
251
- throw new InvalidClientMetadataError(
252
- `Unsupported "userinfo_signed_response_alg" ${metadata.userinfo_signed_response_alg}`,
253
- )
254
- }
255
-
256
- if (
257
- metadata.id_token_signed_response_alg &&
258
- !this.keyset.signAlgorithms.includes(
259
- metadata.id_token_signed_response_alg,
260
- )
261
- ) {
262
- throw new InvalidClientMetadataError(
263
- `Unsupported "id_token_signed_response_alg" ${metadata.id_token_signed_response_alg}`,
264
- )
265
- }
266
-
267
- if (metadata.userinfo_encrypted_response_alg) {
268
- // We only support signature for now.
269
- throw new InvalidClientMetadataError(
270
- 'Encrypted userinfo response is not supported',
271
- )
272
- }
273
-
274
- if (!metadata[`token_endpoint_auth_method`]) {
275
- throw new InvalidClientMetadataError(
276
- 'Missing token_endpoint_auth_method client metadata',
277
- )
278
- }
279
-
280
- for (const endpoint of OAUTH_AUTHENTICATED_ENDPOINT_NAMES) {
281
- const method =
282
- metadata[`${endpoint}_endpoint_auth_method`] ||
283
- metadata[`token_endpoint_auth_method`]
284
-
285
- switch (method) {
286
- case 'none':
287
- if (metadata.token_endpoint_auth_signing_alg) {
288
- throw new InvalidClientMetadataError(
289
- `${endpoint}_endpoint_auth_method "none" must not have ${endpoint}_endpoint_auth_signing_alg`,
290
- )
291
- }
292
- break
272
+ const method = metadata[`token_endpoint_auth_method`]
273
+ switch (method) {
274
+ case undefined:
275
+ throw new InvalidClientMetadataError(
276
+ 'Missing token_endpoint_auth_method client metadata',
277
+ )
293
278
 
294
- case 'private_key_jwt':
295
- if (!metadata.jwks && !metadata.jwks_uri) {
296
- throw new InvalidClientMetadataError(
297
- `private_key_jwt auth method requires jwks or jwks_uri`,
298
- )
299
- }
300
- if (metadata.jwks?.keys.length === 0) {
301
- throw new InvalidClientMetadataError(
302
- `private_key_jwt auth method requires at least one key in jwks`,
303
- )
304
- }
305
- if (!metadata.token_endpoint_auth_signing_alg) {
306
- throw new InvalidClientMetadataError(
307
- `Missing token_endpoint_auth_signing_alg client metadata`,
308
- )
309
- }
310
- break
279
+ case 'none':
280
+ if (metadata.token_endpoint_auth_signing_alg) {
281
+ throw new InvalidClientMetadataError(
282
+ `token_endpoint_auth_method "none" must not have token_endpoint_auth_signing_alg`,
283
+ )
284
+ }
285
+ break
311
286
 
312
- default:
287
+ case 'private_key_jwt':
288
+ if (!metadata.jwks && !metadata.jwks_uri) {
313
289
  throw new InvalidClientMetadataError(
314
- `${method} is not a supported "${endpoint}_endpoint_auth_method". Use "private_key_jwt" or "none".`,
290
+ `private_key_jwt auth method requires jwks or jwks_uri`,
315
291
  )
316
- }
292
+ }
293
+ if (metadata.jwks?.keys.length === 0) {
294
+ throw new InvalidClientMetadataError(
295
+ `private_key_jwt auth method requires at least one key in jwks`,
296
+ )
297
+ }
298
+ if (!metadata.token_endpoint_auth_signing_alg) {
299
+ throw new InvalidClientMetadataError(
300
+ `Missing token_endpoint_auth_signing_alg client metadata`,
301
+ )
302
+ }
303
+ break
304
+
305
+ default:
306
+ throw new InvalidClientMetadataError(
307
+ `${method} is not a supported "token_endpoint_auth_method". Use "private_key_jwt" or "none".`,
308
+ )
317
309
  }
318
310
 
319
311
  if (metadata.authorization_encrypted_response_enc) {
@@ -345,37 +337,28 @@ export class ClientManager {
345
337
  }
346
338
 
347
339
  for (const responseType of metadata.response_types) {
348
- const rt = responseType.split(' ')
340
+ if (responseType.includes('id_token')) {
341
+ throw new InvalidClientMetadataError(
342
+ `OpenID Connect response type "${responseType}" is not supported`,
343
+ )
344
+ }
349
345
 
350
346
  // ATPROTO spec requires the use of PKCE
351
- if (rt.includes('token')) {
347
+ if (responseType !== 'code') {
352
348
  throw new InvalidClientMetadataError(
353
- '"token" response type is not compatible with PKCE (use "code" instead)',
349
+ `Unsupported response type "${responseType}"`,
354
350
  )
355
351
  }
356
352
 
357
353
  // Consistency check
358
354
  if (
359
- rt.includes('code') &&
355
+ responseType === 'code' &&
360
356
  !metadata.grant_types.includes('authorization_code')
361
357
  ) {
362
358
  throw new InvalidClientMetadataError(
363
359
  `Response type "${responseType}" requires the "authorization_code" grant type`,
364
360
  )
365
361
  }
366
-
367
- // Asking for "code token" or "code id_token" is fine (as long as the
368
- // grant_types includes "authorization_code" and the scope includes
369
- // "openid"). Asking for "token" or "id_token" (without "code") requires
370
- // the "implicit" grant type.
371
- if (
372
- (rt.includes('token') || rt.includes('id_token')) &&
373
- !metadata.grant_types.includes('implicit')
374
- ) {
375
- throw new InvalidClientMetadataError(
376
- `Response type "${responseType}" requires the "implicit" grant type`,
377
- )
378
- }
379
362
  }
380
363
 
381
364
  if (metadata.application_type === 'native') {
@@ -390,11 +373,33 @@ export class ClientManager {
390
373
  // > accordingly.
391
374
  }
392
375
 
376
+ if (metadata.authorization_details_types?.length) {
377
+ const dupAuthDetailsType =
378
+ metadata.authorization_details_types.find(isDuplicate)
379
+ if (dupAuthDetailsType) {
380
+ throw new InvalidClientMetadataError(
381
+ `Duplicate authorization_details_type "${dupAuthDetailsType}"`,
382
+ )
383
+ }
384
+
385
+ const authorizationDetailsTypesSupported =
386
+ this.serverMetadata.authorization_details_types_supported
387
+ if (!authorizationDetailsTypesSupported) {
388
+ throw new InvalidClientMetadataError(
389
+ 'authorization_details_types are not supported',
390
+ )
391
+ }
392
+ for (const type of metadata.authorization_details_types) {
393
+ if (!authorizationDetailsTypesSupported.includes(type)) {
394
+ throw new InvalidClientMetadataError(
395
+ `Unsupported authorization_details_type "${type}"`,
396
+ )
397
+ }
398
+ }
399
+ }
400
+
393
401
  if (!metadata.redirect_uris?.length) {
394
- // https://openid.net/specs/openid-connect-registration-1_0.html#rfc.section.2
395
- //
396
- // > OPs can require that request_uri values used be pre-registered with
397
- // > the require_request_uri_registration discovery parameter.
402
+ // ATPROTO spec requires that at least one redirect URI is provided
398
403
 
399
404
  throw new InvalidClientMetadataError(
400
405
  'At least one redirect_uri is required',
@@ -693,16 +698,11 @@ export class ClientManager {
693
698
  )
694
699
  }
695
700
 
696
- for (const endpoint of OAUTH_AUTHENTICATED_ENDPOINT_NAMES) {
697
- const method =
698
- metadata[`${endpoint}_endpoint_auth_method`] ||
699
- metadata[`token_endpoint_auth_method`]
700
-
701
- if (method !== 'none') {
702
- throw new InvalidClientMetadataError(
703
- `Loopback clients are not allowed to use "${endpoint}_endpoint_auth_method" ${method}`,
704
- )
705
- }
701
+ const method = metadata[`token_endpoint_auth_method`]
702
+ if (method !== 'none') {
703
+ throw new InvalidClientMetadataError(
704
+ `Loopback clients are not allowed to use "token_endpoint_auth_method" ${method}`,
705
+ )
706
706
  }
707
707
 
708
708
  for (const redirectUri of metadata.redirect_uris) {
@@ -766,16 +766,14 @@ export class ClientManager {
766
766
  }
767
767
  }
768
768
 
769
- for (const endpoint of OAUTH_AUTHENTICATED_ENDPOINT_NAMES) {
770
- const method = metadata[`${endpoint}_endpoint_auth_method`]
771
- switch (method) {
772
- case 'client_secret_post':
773
- case 'client_secret_basic':
774
- case 'client_secret_jwt':
775
- throw new InvalidClientMetadataError(
776
- `Client authentication method "${method}" is not allowed for discoverable clients`,
777
- )
778
- }
769
+ const method = metadata[`token_endpoint_auth_method`]
770
+ switch (method) {
771
+ case 'client_secret_post':
772
+ case 'client_secret_basic':
773
+ case 'client_secret_jwt':
774
+ throw new InvalidClientMetadataError(
775
+ `Client authentication method "${method}" is not allowed for discoverable clients`,
776
+ )
779
777
  }
780
778
 
781
779
  for (const redirectUri of metadata.redirect_uris) {
@@ -800,6 +798,12 @@ export class ClientManager {
800
798
  }
801
799
  }
802
800
 
801
+ function isDuplicate<
802
+ T extends string | number | boolean | null | undefined | symbol,
803
+ >(value: T, index: number, array: T[]) {
804
+ return array.includes(value, index + 1)
805
+ }
806
+
803
807
  function reverseDomain(domain: string) {
804
808
  return domain.split('.').reverse().join('.')
805
809
  }