@_mustachio/openauth 0.10.0 → 0.11.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 (151) hide show
  1. package/dist/esm/adapters/memory/session-store.js +22 -0
  2. package/dist/esm/adapters/memory/token-store.js +13 -0
  3. package/dist/esm/client.js +9 -2
  4. package/dist/esm/domain/authorize.js +5 -0
  5. package/dist/esm/domain/callback.js +3 -0
  6. package/dist/esm/domain/discovery.js +44 -2
  7. package/dist/esm/domain/dpop.js +122 -0
  8. package/dist/esm/domain/id-token.js +94 -0
  9. package/dist/esm/domain/introspect.js +5 -1
  10. package/dist/esm/domain/jwt.js +28 -0
  11. package/dist/esm/domain/logout.js +75 -0
  12. package/dist/esm/domain/method-route.js +3 -0
  13. package/dist/esm/domain/par.js +51 -0
  14. package/dist/esm/domain/refresh.js +12 -1
  15. package/dist/esm/domain/register.js +74 -0
  16. package/dist/esm/domain/token-exchange.js +1 -0
  17. package/dist/esm/domain/token.js +48 -7
  18. package/dist/esm/domain/userinfo.js +38 -3
  19. package/dist/esm/http/handlers/authorize.js +90 -3
  20. package/dist/esm/http/handlers/end-session.js +64 -0
  21. package/dist/esm/http/handlers/metadata.js +5 -2
  22. package/dist/esm/http/handlers/par.js +53 -0
  23. package/dist/esm/http/handlers/register.js +46 -0
  24. package/dist/esm/http/handlers/token.js +36 -6
  25. package/dist/esm/http/handlers/userinfo.js +35 -29
  26. package/dist/esm/http/router.js +10 -0
  27. package/dist/esm/http/schemas/authorize.js +7 -1
  28. package/dist/esm/http/schemas/end-session.js +13 -0
  29. package/dist/esm/http/schemas/par.js +9 -0
  30. package/dist/esm/http/schemas/register.js +16 -0
  31. package/dist/esm/index.js +7 -2
  32. package/dist/esm/types/error.js +5 -0
  33. package/dist/types/adapters/memory/session-store.d.ts +3 -1
  34. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  35. package/dist/types/adapters/memory/token-store.d.ts +1 -0
  36. package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
  37. package/dist/types/client.d.ts +19 -0
  38. package/dist/types/client.d.ts.map +1 -1
  39. package/dist/types/domain/authorize.d.ts.map +1 -1
  40. package/dist/types/domain/client-credentials.d.ts +2 -0
  41. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  42. package/dist/types/domain/discovery.d.ts +37 -0
  43. package/dist/types/domain/discovery.d.ts.map +1 -1
  44. package/dist/types/domain/dpop.d.ts +61 -0
  45. package/dist/types/domain/dpop.d.ts.map +1 -0
  46. package/dist/types/domain/id-token.d.ts +83 -0
  47. package/dist/types/domain/id-token.d.ts.map +1 -0
  48. package/dist/types/domain/introspect.d.ts +8 -0
  49. package/dist/types/domain/introspect.d.ts.map +1 -1
  50. package/dist/types/domain/jwt.d.ts +27 -1
  51. package/dist/types/domain/jwt.d.ts.map +1 -1
  52. package/dist/types/domain/logout.d.ts +69 -0
  53. package/dist/types/domain/logout.d.ts.map +1 -0
  54. package/dist/types/domain/par.d.ts +62 -0
  55. package/dist/types/domain/par.d.ts.map +1 -0
  56. package/dist/types/domain/refresh.d.ts +9 -0
  57. package/dist/types/domain/refresh.d.ts.map +1 -1
  58. package/dist/types/domain/register.d.ts +33 -0
  59. package/dist/types/domain/register.d.ts.map +1 -0
  60. package/dist/types/domain/token-exchange.d.ts +2 -0
  61. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  62. package/dist/types/domain/token.d.ts +41 -1
  63. package/dist/types/domain/token.d.ts.map +1 -1
  64. package/dist/types/domain/userinfo.d.ts +44 -5
  65. package/dist/types/domain/userinfo.d.ts.map +1 -1
  66. package/dist/types/http/context.d.ts +4 -1
  67. package/dist/types/http/context.d.ts.map +1 -1
  68. package/dist/types/http/handlers/authorize.d.ts.map +1 -1
  69. package/dist/types/http/handlers/end-session.d.ts +3 -0
  70. package/dist/types/http/handlers/end-session.d.ts.map +1 -0
  71. package/dist/types/http/handlers/metadata.d.ts +1 -1
  72. package/dist/types/http/handlers/metadata.d.ts.map +1 -1
  73. package/dist/types/http/handlers/par.d.ts +3 -0
  74. package/dist/types/http/handlers/par.d.ts.map +1 -0
  75. package/dist/types/http/handlers/register.d.ts +3 -0
  76. package/dist/types/http/handlers/register.d.ts.map +1 -0
  77. package/dist/types/http/handlers/token.d.ts.map +1 -1
  78. package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
  79. package/dist/types/http/router.d.ts.map +1 -1
  80. package/dist/types/http/schemas/authorize.d.ts +40 -0
  81. package/dist/types/http/schemas/authorize.d.ts.map +1 -1
  82. package/dist/types/http/schemas/end-session.d.ts +31 -0
  83. package/dist/types/http/schemas/end-session.d.ts.map +1 -0
  84. package/dist/types/http/schemas/par.d.ts +20 -0
  85. package/dist/types/http/schemas/par.d.ts.map +1 -0
  86. package/dist/types/http/schemas/register.d.ts +42 -0
  87. package/dist/types/http/schemas/register.d.ts.map +1 -0
  88. package/dist/types/index.d.ts +3 -3
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/types/ports/audit-log.d.ts +27 -0
  91. package/dist/types/ports/audit-log.d.ts.map +1 -1
  92. package/dist/types/ports/session-store.d.ts +27 -0
  93. package/dist/types/ports/session-store.d.ts.map +1 -1
  94. package/dist/types/ports/token-store.d.ts +12 -0
  95. package/dist/types/ports/token-store.d.ts.map +1 -1
  96. package/dist/types/types/authorization.d.ts +23 -0
  97. package/dist/types/types/authorization.d.ts.map +1 -1
  98. package/dist/types/types/error.d.ts +9 -0
  99. package/dist/types/types/error.d.ts.map +1 -1
  100. package/dist/types/types/flow.d.ts +10 -0
  101. package/dist/types/types/flow.d.ts.map +1 -1
  102. package/dist/types/types/idp.d.ts +91 -1
  103. package/dist/types/types/idp.d.ts.map +1 -1
  104. package/dist/types/types/tenant.d.ts +37 -0
  105. package/dist/types/types/tenant.d.ts.map +1 -1
  106. package/dist/types/types/token.d.ts +128 -0
  107. package/dist/types/types/token.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/src/adapters/memory/session-store.ts +44 -1
  110. package/src/adapters/memory/token-store.ts +16 -0
  111. package/src/client.ts +32 -0
  112. package/src/domain/authorize.ts +9 -0
  113. package/src/domain/callback.ts +5 -0
  114. package/src/domain/client-credentials.ts +2 -0
  115. package/src/domain/discovery.ts +80 -2
  116. package/src/domain/dpop.ts +293 -0
  117. package/src/domain/id-token.ts +213 -0
  118. package/src/domain/introspect.ts +10 -0
  119. package/src/domain/jwt.ts +80 -1
  120. package/src/domain/logout.ts +180 -0
  121. package/src/domain/method-route.ts +5 -0
  122. package/src/domain/par.ts +141 -0
  123. package/src/domain/refresh.ts +41 -0
  124. package/src/domain/register.ts +168 -0
  125. package/src/domain/token-exchange.ts +11 -0
  126. package/src/domain/token.ts +134 -8
  127. package/src/domain/userinfo.ts +121 -6
  128. package/src/http/context.ts +4 -0
  129. package/src/http/handlers/authorize.ts +144 -3
  130. package/src/http/handlers/end-session.ts +106 -0
  131. package/src/http/handlers/metadata.ts +7 -2
  132. package/src/http/handlers/par.ts +84 -0
  133. package/src/http/handlers/register.ts +70 -0
  134. package/src/http/handlers/token.ts +56 -0
  135. package/src/http/handlers/userinfo.ts +62 -38
  136. package/src/http/router.ts +19 -0
  137. package/src/http/schemas/authorize.ts +27 -0
  138. package/src/http/schemas/end-session.ts +23 -0
  139. package/src/http/schemas/par.ts +23 -0
  140. package/src/http/schemas/register.ts +29 -0
  141. package/src/index.ts +18 -1
  142. package/src/ports/CONSISTENCY.md +52 -21
  143. package/src/ports/audit-log.ts +29 -0
  144. package/src/ports/session-store.ts +33 -0
  145. package/src/ports/token-store.ts +13 -0
  146. package/src/types/authorization.ts +25 -0
  147. package/src/types/error.ts +23 -0
  148. package/src/types/flow.ts +10 -0
  149. package/src/types/idp.ts +96 -0
  150. package/src/types/tenant.ts +37 -0
  151. package/src/types/token.ts +135 -0
@@ -47,7 +47,8 @@ import {
47
47
  sha256,
48
48
  utf8,
49
49
  } from "./crypto"
50
- import { signAccessToken } from "./jwt"
50
+ import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token"
51
+ import { signAccessToken, signIdToken } from "./jwt"
51
52
  import { validatePkce } from "./pkce"
52
53
 
53
54
  /**
@@ -88,6 +89,13 @@ export type TokenAuthCodeRequest = {
88
89
  clientSecret?: string
89
90
  /** Required if the original `/authorize` request had a `code_challenge`. */
90
91
  codeVerifier?: string
92
+ /**
93
+ * RFC 9449 §6 — the JWK thumbprint of the presented DPoP proof. Set by
94
+ * the HTTP layer after verifying the `DPoP:` header against this
95
+ * request's actual method + URI. The domain binds the issued access
96
+ * token to this thumbprint via `cnf.jkt`.
97
+ */
98
+ dpopJkt?: string
91
99
  }
92
100
 
93
101
  export type ExchangeCodeDeps = {
@@ -102,6 +110,11 @@ export type ExchangeCodeDeps = {
102
110
  newRefreshToken?: () => string
103
111
  /** Test override. */
104
112
  newRefreshFamily?: () => string
113
+ /**
114
+ * Host-supplied vendor scope → claim-names map. Forwarded to `mintTokens`
115
+ * for id_token + /userinfo scope-gating. See `IdPOptions.customScopeClaims`.
116
+ */
117
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
105
118
  }
106
119
 
107
120
  export async function exchangeCode(
@@ -141,6 +154,17 @@ export async function exchangeCode(
141
154
  const authResult = await verifyClientCredentials(client, req.clientSecret)
142
155
  if (authResult) return err(authResult)
143
156
 
157
+ // RFC 9449 §5.2 — if the client is configured to require DPoP, a
158
+ // bearer-only request (no DPoP header → no `dpopJkt` threaded in) is
159
+ // refused with `invalid_dpop_proof` before any token is minted.
160
+ if (client.dpopRequired && req.dpopJkt === undefined) {
161
+ return err(
162
+ authError.invalidDpopProof(
163
+ `client "${client.id}" requires DPoP-bound tokens`,
164
+ ),
165
+ )
166
+ }
167
+
144
168
  // 4. Redirect URI binding.
145
169
  if (payload.appRedirectUri !== req.redirectUri) {
146
170
  return err(authError.invalidGrant("redirect_uri mismatch with auth code"))
@@ -198,11 +222,14 @@ export async function exchangeCode(
198
222
  }
199
223
  }
200
224
 
201
- // 8. Mint access + refresh.
225
+ // 8. Mint access + refresh (+ id_token if `openid` scope was granted).
202
226
  const minted = await mintTokens({
203
227
  tenant,
204
228
  claim,
205
- payload,
229
+ payload: {
230
+ ...payload,
231
+ ...(req.dpopJkt !== undefined ? { dpopJkt: req.dpopJkt } : {}),
232
+ },
206
233
  deps,
207
234
  family: (deps.newRefreshFamily ?? randomId)(),
208
235
  })
@@ -219,7 +246,30 @@ export async function mintTokens(args: {
219
246
  payload: Pick<
220
247
  CodePayload,
221
248
  "tenantId" | "clientId" | "methodId" | "methodKind" | "scopes" | "audience"
222
- >
249
+ > & {
250
+ /**
251
+ * Present on `authorization_code` and `refresh_token` grants where an
252
+ * end-user actually authenticated. Absent on `client_credentials`
253
+ * (no end-user) — and absent means no `id_token` is emitted even if
254
+ * the requested scopes nominally include `openid`.
255
+ */
256
+ authTime?: number
257
+ /** RP's OIDC `nonce` from `/authorize`, when present. */
258
+ appNonce?: string
259
+ /**
260
+ * DPoP key thumbprint (RFC 9449 §6.1). When present, the access
261
+ * token's `cnf.jkt` claim is set and `token_type` flips to `"DPoP"`;
262
+ * the saved refresh-token payload's `dpopJkt` is set so refresh
263
+ * rotation re-enforces sender constraint.
264
+ */
265
+ dpopJkt?: string
266
+ /**
267
+ * OIDC Core §5.5 — RP-requested claims from `/authorize`. Carried
268
+ * into the id_token and forward across refresh rotations (§12) so
269
+ * later /userinfo calls keep returning the requested fields.
270
+ */
271
+ claimsRequest?: import("../types/authorization").ClaimsRequest
272
+ }
223
273
  family: string
224
274
  /**
225
275
  * `client_credentials` grants (RFC 6749 §4.4.3) and other paths where a
@@ -236,6 +286,11 @@ export async function mintTokens(args: {
236
286
  issuerUrl: string
237
287
  clock: () => number
238
288
  newRefreshToken?: () => string
289
+ /**
290
+ * Host-supplied vendor scope → claim-names map merged into the
291
+ * id_token + /userinfo scope-gating. See `IdPOptions.customScopeClaims`.
292
+ */
293
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
239
294
  }
240
295
  }): Promise<Result<TokenResponse, AuthError>> {
241
296
  const { tenant, claim, payload, deps, family, skipRefresh } = args
@@ -248,12 +303,22 @@ export async function mintTokens(args: {
248
303
  ? tenant.config.refreshTtl * 1000
249
304
  : DEFAULT_REFRESH_TTL_MS
250
305
  const now = deps.clock()
251
- const subjectId = await deriveSubjectId(claim)
306
+ // OIDC Core §8.1 — `sectorIdentifier` from the receiving client drives
307
+ // pairwise vs public subject derivation. Look it up off the tenant
308
+ // config (already loaded by the grant flow). Absent = public.
309
+ const receivingClient = tenant.config.clients.find(
310
+ (c) => c.id === payload.clientId,
311
+ )
312
+ const subjectId = await deriveSubjectId(
313
+ claim,
314
+ receivingClient?.sectorIdentifier,
315
+ )
252
316
 
253
317
  const keyRes = await deps.keyStore.currentSigningKey()
254
318
  if (isErr(keyRes)) return err(keyRes.error)
255
319
  const signingKey = keyRes.value
256
320
 
321
+ const userinfoClaimNames = Object.keys(payload.claimsRequest?.userinfo ?? {})
257
322
  const claims: AccessTokenClaims = {
258
323
  iss: deps.issuerUrl,
259
324
  sub: subjectId,
@@ -265,6 +330,9 @@ export async function mintTokens(args: {
265
330
  mkind: payload.methodKind,
266
331
  scope: payload.scopes.join(" "),
267
332
  claim,
333
+ ...(payload.authTime !== undefined ? { auth_time: payload.authTime } : {}),
334
+ ...(payload.dpopJkt !== undefined ? { cnf: { jkt: payload.dpopJkt } } : {}),
335
+ ...(userinfoClaimNames.length > 0 ? { uic: userinfoClaimNames } : {}),
268
336
  }
269
337
 
270
338
  let accessToken: string
@@ -295,6 +363,14 @@ export async function mintTokens(args: {
295
363
  family,
296
364
  methodId: payload.methodId,
297
365
  methodKind: payload.methodKind,
366
+ // Carry the original end-user auth time forward. Refresh-grant
367
+ // reissue uses this verbatim so `id_token.auth_time` is stable per
368
+ // OIDC Core §12 (refresh does not re-authenticate the user).
369
+ authTime: payload.authTime ?? Math.floor(now / 1000),
370
+ ...(payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {}),
371
+ ...(payload.claimsRequest !== undefined
372
+ ? { claimsRequest: payload.claimsRequest }
373
+ : {}),
298
374
  issuedAt: now,
299
375
  expiresAt: now + refreshTtl,
300
376
  }
@@ -302,6 +378,41 @@ export async function mintTokens(args: {
302
378
  if (isErr(saved)) return err(saved.error)
303
379
  }
304
380
 
381
+ // OIDC id_token issuance — only when the grant carried an end-user
382
+ // (`authTime` present) AND `openid` scope was granted. Client-credentials
383
+ // never satisfies the first condition; refresh + code do.
384
+ let idToken: string | undefined
385
+ if (payload.authTime !== undefined && shouldIssueIdToken(payload.scopes)) {
386
+ const idClaims = await buildIdTokenClaims({
387
+ issuerUrl: deps.issuerUrl,
388
+ audience: payload.clientId,
389
+ subjectId,
390
+ claim,
391
+ scopes: payload.scopes,
392
+ authTime: payload.authTime,
393
+ ...(payload.appNonce !== undefined ? { appNonce: payload.appNonce } : {}),
394
+ now,
395
+ methodKind: payload.methodKind,
396
+ accessToken,
397
+ ...(payload.claimsRequest !== undefined
398
+ ? { claimsRequest: payload.claimsRequest }
399
+ : {}),
400
+ ...(deps.customScopeClaims !== undefined
401
+ ? { customScopeClaims: deps.customScopeClaims }
402
+ : {}),
403
+ })
404
+ try {
405
+ idToken = await signIdToken(
406
+ idClaims,
407
+ signingKey.privateKeyRef as Parameters<typeof signIdToken>[1],
408
+ signingKey.alg,
409
+ signingKey.kid,
410
+ )
411
+ } catch (e) {
412
+ return err(authError.serverError("id_token sign failed", e))
413
+ }
414
+ }
415
+
305
416
  await safeAudit(deps, {
306
417
  kind: "token_issued",
307
418
  tenantId: payload.tenantId,
@@ -310,14 +421,17 @@ export async function mintTokens(args: {
310
421
  methodKind: payload.methodKind,
311
422
  subjectId,
312
423
  refreshTokenIdHash: refresh ? await hashTokenForAudit(refresh) : "",
424
+ ...(idToken !== undefined ? { idTokenIssued: true } : {}),
425
+ ...(payload.dpopJkt !== undefined ? { dpopBound: true } : {}),
313
426
  timestamp: now,
314
427
  })
315
428
 
316
429
  return ok({
317
430
  access_token: accessToken,
318
- token_type: "Bearer",
431
+ token_type: payload.dpopJkt !== undefined ? "DPoP" : "Bearer",
319
432
  expires_in: Math.floor(accessTtl / 1000),
320
433
  ...(refresh !== undefined ? { refresh_token: refresh } : {}),
434
+ ...(idToken !== undefined ? { id_token: idToken } : {}),
321
435
  scope: payload.scopes.join(" "),
322
436
  })
323
437
  }
@@ -336,11 +450,23 @@ export async function hashClientSecret(plain: string): Promise<string> {
336
450
  /**
337
451
  * Derive a stable subject id from the issued `SubjectClaim`. Hash inputs
338
452
  * are canonicalized so reordered `properties` keys hash identically.
453
+ *
454
+ * OIDC Core §8.1 — when `sectorIdentifier` is supplied, the derivation
455
+ * mixes it in so the resulting `sub` is **pairwise**: identical across
456
+ * clients sharing that sector, distinct across sectors. Absent =
457
+ * **public** subject (same `sub` for every RP).
339
458
  */
340
- async function deriveSubjectId(claim: SubjectClaim): Promise<string> {
459
+ async function deriveSubjectId(
460
+ claim: SubjectClaim,
461
+ sectorIdentifier?: string,
462
+ ): Promise<string> {
341
463
  const c = claim as { type: string; properties: Record<string, unknown> }
342
464
  const ordered = canonicalize(c.properties)
343
- return base64url.encode(await sha256(`${c.type}\0${ordered}`)).slice(0, 22)
465
+ const seed =
466
+ sectorIdentifier !== undefined
467
+ ? `${sectorIdentifier}\0${c.type}\0${ordered}`
468
+ : `${c.type}\0${ordered}`
469
+ return base64url.encode(await sha256(seed)).slice(0, 22)
344
470
  }
345
471
 
346
472
  function canonicalize(value: unknown): string {
@@ -1,7 +1,23 @@
1
1
  /**
2
- * `/userinfo` endpoint logic (OIDC Core §5.3). Verifies the bearer access
3
- * token and returns the inlined `SubjectClaim` claims.
2
+ * `/userinfo` endpoint logic (OIDC Core §5.3). Verifies the presented
3
+ * access token, optionally enforces a DPoP proof binding (RFC 9449 §7),
4
+ * and returns the inlined `SubjectClaim` claims, scope-gated per OIDC
5
+ * Core §5.4.
6
+ *
7
+ * Profile / email / phone / address claims appear in the response only
8
+ * when (a) the access token's `scope` includes the granting scope and
9
+ * (b) the value exists on `SubjectClaim.properties`. Other properties
10
+ * (host-specific subject fields like `userId`, `roles`, …) are returned
11
+ * under `properties` and not subject to OIDC scope gating — they're
12
+ * outside §5.4's universe.
13
+ *
14
+ * DPoP at the resource server (RFC 9449 §7):
15
+ * - If the access token has a `cnf.jkt` claim, the request MUST carry a
16
+ * matching `DPoP:` proof whose `ath` equals SHA-256(access_token).
17
+ * - If no `cnf.jkt`, the access token is plain Bearer and any DPoP
18
+ * proof is silently ignored at this layer (host can still enforce).
4
19
  */
20
+ import type { TokenStore } from "../ports/token-store"
5
21
  import type { KeyStore } from "../ports/key-store"
6
22
  import { authError, type AuthError } from "../types/error"
7
23
  import type { Result } from "../types/result"
@@ -9,13 +25,16 @@ import { err, isErr, ok } from "../types/result"
9
25
  import type { SubjectClaim } from "../types/subject"
10
26
  import type { AccessTokenClaims } from "../types/token"
11
27
 
28
+ import { computeAth, verifyDpopProof } from "./dpop"
29
+ import { pickScopedClaims } from "./id-token"
12
30
  import { verifyAccessToken } from "./jwt"
31
+ import type { ScopedProfileClaims } from "../types/token"
13
32
 
14
- export type UserinfoResponse = {
33
+ export type UserinfoResponse = ScopedProfileClaims & {
15
34
  sub: string
16
35
  /** Subject type discriminator (e.g. `"user"` / `"admin"`). */
17
36
  subject_type: string
18
- /** Inlined claim properties. */
37
+ /** Inlined claim properties, host-specific and not gated by OIDC scope. */
19
38
  properties: Record<string, unknown>
20
39
  /** Scope granted to this access token. */
21
40
  scope?: string
@@ -23,11 +42,34 @@ export type UserinfoResponse = {
23
42
 
24
43
  export type UserinfoDeps = {
25
44
  keyStore: KeyStore
45
+ /** Required when DPoP-bound tokens are accepted. */
46
+ tokenStore?: TokenStore
26
47
  issuerUrl?: string
48
+ /**
49
+ * Host-supplied vendor scope → claim-names map. Merged into the
50
+ * scope-gated /userinfo response just as it is for id_token. See
51
+ * `IdPOptions.customScopeClaims`.
52
+ */
53
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
54
+ }
55
+
56
+ export type UserinfoInput = {
57
+ /** Access token from `Authorization: Bearer ...` or `Authorization: DPoP ...`. */
58
+ accessToken: string
59
+ /** `Authorization`-scheme as presented. Defaults to `"Bearer"`. */
60
+ scheme?: "Bearer" | "DPoP"
61
+ /** Raw `DPoP:` header from the request, if any. */
62
+ dpopProof?: string
63
+ /** Canonical request URI for DPoP `htu` check (scheme + host + path). */
64
+ htu?: string
65
+ /** Request method for DPoP `htm` check, uppercase. */
66
+ htm?: string
67
+ /** Wall clock seconds for DPoP `iat` window. */
68
+ nowSec?: number
27
69
  }
28
70
 
29
71
  export async function userinfo(
30
- bearerToken: string,
72
+ input: UserinfoInput,
31
73
  deps: UserinfoDeps,
32
74
  ): Promise<Result<UserinfoResponse, AuthError>> {
33
75
  const keysRes = await deps.keyStore.signingKeys()
@@ -35,22 +77,95 @@ export async function userinfo(
35
77
 
36
78
  let claims: AccessTokenClaims
37
79
  try {
38
- claims = await verifyAccessToken(bearerToken, keysRes.value, {
80
+ claims = await verifyAccessToken(input.accessToken, keysRes.value, {
39
81
  ...(deps.issuerUrl ? { issuer: deps.issuerUrl } : {}),
40
82
  })
41
83
  } catch {
42
84
  return err(authError.invalidGrant("access token invalid or expired"))
43
85
  }
44
86
 
87
+ // RFC 9449 §7 — DPoP-bound tokens require a matching proof at the RS.
88
+ const boundJkt = claims.cnf?.jkt
89
+ if (boundJkt !== undefined) {
90
+ if (input.scheme !== "DPoP") {
91
+ return err(
92
+ authError.invalidDpopProof(
93
+ 'access token is DPoP-bound; Authorization scheme must be "DPoP"',
94
+ ),
95
+ )
96
+ }
97
+ if (!input.dpopProof) {
98
+ return err(
99
+ authError.invalidDpopProof(
100
+ "access token is DPoP-bound; request is missing a DPoP proof",
101
+ ),
102
+ )
103
+ }
104
+ if (!input.htu || !input.htm || input.nowSec === undefined) {
105
+ return err(
106
+ authError.invalidDpopProof(
107
+ "userinfo dpop verification requires htu, htm, and nowSec",
108
+ ),
109
+ )
110
+ }
111
+ if (!deps.tokenStore) {
112
+ return err(
113
+ authError.invalidDpopProof(
114
+ "userinfo dpop verification requires a token-store (jti replay protection)",
115
+ ),
116
+ )
117
+ }
118
+ const expectedAth = await computeAth(input.accessToken)
119
+ const dpopRes = await verifyDpopProof(
120
+ {
121
+ proofJwt: input.dpopProof,
122
+ htu: input.htu,
123
+ htm: input.htm,
124
+ nowSec: input.nowSec,
125
+ expectedAth,
126
+ },
127
+ { tokenStore: deps.tokenStore },
128
+ )
129
+ if (isErr(dpopRes)) return err(dpopRes.error)
130
+ if (dpopRes.value.jkt !== boundJkt) {
131
+ return err(
132
+ authError.invalidDpopProof(
133
+ "dpop proof key does not match access token cnf.jkt",
134
+ ),
135
+ )
136
+ }
137
+ } else if (input.scheme === "DPoP") {
138
+ // Token isn't bound but caller used the DPoP scheme — reject so a
139
+ // confused-deputy can't accidentally rely on RS-side enforcement.
140
+ return err(
141
+ authError.invalidGrant(
142
+ "access token is not DPoP-bound; use Authorization: Bearer",
143
+ ),
144
+ )
145
+ }
146
+
45
147
  const claim = claims.claim as SubjectClaim & {
46
148
  type: string
47
149
  properties: Record<string, unknown>
48
150
  }
49
151
 
152
+ const scopes = claims.scope ? claims.scope.split(" ").filter(Boolean) : []
153
+ // OIDC Core §5.5 — claims the RP requested via `claims=...userinfo:...`
154
+ // bypass scope gating. Names were captured into `uic` at mint time so
155
+ // the resource server doesn't need to re-resolve the original /authorize
156
+ // request.
157
+ const scopedClaims = pickScopedClaims(
158
+ claim,
159
+ scopes,
160
+ claims.uic ?? [],
161
+ deps.customScopeClaims ?? {},
162
+ )
163
+
50
164
  return ok({
51
165
  sub: claims.sub,
52
166
  subject_type: claim.type,
53
167
  properties: claim.properties,
54
168
  scope: claims.scope,
169
+ ...scopedClaims,
55
170
  })
56
171
  }
@@ -18,6 +18,7 @@ import type { AuthError } from "../types/error"
18
18
  import type {
19
19
  ExchangeAudience,
20
20
  PersistUpstreamTokens,
21
+ RegisterClient,
21
22
  RenderPicker,
22
23
  SuccessMapInput,
23
24
  } from "../types/idp"
@@ -51,12 +52,15 @@ export type HttpDeps = {
51
52
  persistUpstreamTokens?: PersistUpstreamTokens
52
53
  exchangeAudience?: ExchangeAudience
53
54
  renderPicker?: RenderPicker
55
+ registerClient?: RegisterClient
54
56
  /** Builds `TenantContext.request.custom` for every request. See `IdPOptions.buildCustomContext`. */
55
57
  buildCustomContext?: (
56
58
  req: Request,
57
59
  ) => Record<string, unknown> | Promise<Record<string, unknown>>
58
60
  clock: () => number
59
61
  cookieDefaults: CookieDefaults
62
+ /** See `IdPOptions.customScopeClaims`. */
63
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
60
64
  }
61
65
 
62
66
  /** Per-request variables populated by middleware. */
@@ -17,7 +17,10 @@ import { isErr } from "../../types/result"
17
17
  import { startAuthorize } from "../../domain/authorize"
18
18
  import { asTenantId } from "../../types/tenant"
19
19
  import { authError } from "../../types/error"
20
- import type { AuthorizationRequest } from "../../types/authorization"
20
+ import type {
21
+ AuthorizationRequest,
22
+ ClaimsRequest,
23
+ } from "../../types/authorization"
21
24
  import type { PickerContext, PickerMethod } from "../../types/picker"
22
25
  import { renderPicker as renderDefaultPicker } from "../../ui/picker"
23
26
 
@@ -28,7 +31,10 @@ import {
28
31
  authorizeRedirectErrorResponse,
29
32
  isNonRecoverable,
30
33
  } from "../errors"
31
- import { authorizeQuerySchema } from "../schemas/authorize"
34
+ import {
35
+ authorizeQuerySchema,
36
+ authorizeRequestUriQuerySchema,
37
+ } from "../schemas/authorize"
32
38
 
33
39
  export function makeAuthorizeHandler(deps: HttpDeps) {
34
40
  return async (c: HttpContext): Promise<Response> => {
@@ -41,7 +47,51 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
41
47
 
42
48
  const url = new URL(c.req.url)
43
49
  const raw = Object.fromEntries(url.searchParams.entries())
44
- const parsed = authorizeQuerySchema.safeParse(raw)
50
+
51
+ // RFC 9126 §4: rehydrate a PAR record when `request_uri` is present.
52
+ // The user-agent URL carries only `client_id` + `request_uri`; the
53
+ // stored params drive the rest of the request through the standard
54
+ // parser below.
55
+ let workingRaw: Record<string, string> = raw
56
+ if (raw.request_uri !== undefined) {
57
+ const parParsed = authorizeRequestUriQuerySchema.safeParse(raw)
58
+ if (!parParsed.success) {
59
+ return authorizeDirectErrorResponse(
60
+ authError.invalidRequest(
61
+ parParsed.error.issues[0]?.message ?? "invalid request",
62
+ parParsed.error.issues[0]?.path[0]?.toString() ?? "request",
63
+ ),
64
+ )
65
+ }
66
+ if (!deps.sessionStore.consumePar) {
67
+ return authorizeDirectErrorResponse(
68
+ authError.invalidRequest(
69
+ "session adapter does not support PAR",
70
+ "request_uri",
71
+ ),
72
+ )
73
+ }
74
+ const par = await deps.sessionStore.consumePar(parParsed.data.request_uri)
75
+ if (isErr(par)) {
76
+ return authorizeDirectErrorResponse(
77
+ authError.invalidRequest(
78
+ "unknown or expired request_uri",
79
+ "request_uri",
80
+ ),
81
+ )
82
+ }
83
+ if (par.value.clientId !== parParsed.data.client_id) {
84
+ return authorizeDirectErrorResponse(
85
+ authError.invalidRequest(
86
+ "request_uri belongs to a different client",
87
+ "request_uri",
88
+ ),
89
+ )
90
+ }
91
+ workingRaw = par.value.params
92
+ }
93
+
94
+ const parsed = authorizeQuerySchema.safeParse(workingRaw)
45
95
  if (!parsed.success) {
46
96
  const first = parsed.error.issues[0]
47
97
  const field = first?.path[0]?.toString() ?? "request"
@@ -51,6 +101,20 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
51
101
  }
52
102
  const q = parsed.data
53
103
 
104
+ // RFC 9126 §2 per-client PAR enforcement: refuse direct `/authorize`
105
+ // when the client requires PAR and `request_uri` was absent.
106
+ if (raw.request_uri === undefined) {
107
+ const client = tenant.config.clients.find((c) => c.id === q.client_id)
108
+ if (client?.requirePushedAuthorizationRequests) {
109
+ return authorizeDirectErrorResponse(
110
+ authError.invalidRequest(
111
+ "this client requires Pushed Authorization Requests (RFC 9126)",
112
+ "request_uri",
113
+ ),
114
+ )
115
+ }
116
+ }
117
+
54
118
  if (q.response_type !== "code") {
55
119
  // OAuth 2.1: only `code` is supported. Implicit (`token`) is removed.
56
120
  return authorizeDirectErrorResponse({
@@ -60,6 +124,17 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
60
124
  })
61
125
  }
62
126
 
127
+ let claimsRequest: ClaimsRequest | undefined
128
+ if (q.claims !== undefined) {
129
+ const parsedClaims = parseClaimsParameter(q.claims)
130
+ if (!parsedClaims.ok) {
131
+ return authorizeDirectErrorResponse(
132
+ authError.invalidRequest(parsedClaims.error, "claims"),
133
+ )
134
+ }
135
+ claimsRequest = parsedClaims.value
136
+ }
137
+
63
138
  const request: AuthorizationRequest = {
64
139
  tenantId: asTenantId(tenant.id),
65
140
  clientId: q.client_id,
@@ -78,6 +153,7 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
78
153
  ...(q.prompt !== undefined ? { prompt: q.prompt } : {}),
79
154
  ...(q.ui_locales !== undefined ? { uiLocales: q.ui_locales } : {}),
80
155
  ...(q.nonce !== undefined ? { nonce: q.nonce } : {}),
156
+ ...(claimsRequest !== undefined ? { claimsRequest } : {}),
81
157
  }
82
158
 
83
159
  const result = await startAuthorize(
@@ -189,6 +265,71 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
189
265
  }
190
266
  }
191
267
 
268
+ /**
269
+ * Parse + validate the OIDC Core §5.5 `claims` parameter. Accepts a
270
+ * JSON string; verifies it's an object whose `userinfo` / `id_token`
271
+ * values, if present, are objects mapping claim names to `null` or
272
+ * `{essential?, value?, values?}` entries. Returns a discriminated
273
+ * `{ok, value}` shape so the caller can map failure → `invalid_request`
274
+ * without dragging the AuthError import into this helper.
275
+ */
276
+ function parseClaimsParameter(
277
+ raw: string,
278
+ ): { ok: true; value: ClaimsRequest } | { ok: false; error: string } {
279
+ let parsed: unknown
280
+ try {
281
+ parsed = JSON.parse(raw)
282
+ } catch (e) {
283
+ return {
284
+ ok: false,
285
+ error: `claims parameter is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
286
+ }
287
+ }
288
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
289
+ return { ok: false, error: "claims parameter must be a JSON object" }
290
+ }
291
+ const obj = parsed as Record<string, unknown>
292
+ const result: ClaimsRequest = {}
293
+ for (const section of ["userinfo", "id_token"] as const) {
294
+ const v = obj[section]
295
+ if (v === undefined) continue
296
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
297
+ return {
298
+ ok: false,
299
+ error: `claims.${section} must be a JSON object`,
300
+ }
301
+ }
302
+ const sectionMap: Record<
303
+ string,
304
+ { essential?: boolean; value?: unknown; values?: unknown[] } | null
305
+ > = {}
306
+ for (const [name, entry] of Object.entries(v)) {
307
+ if (entry === null) {
308
+ sectionMap[name] = null
309
+ continue
310
+ }
311
+ if (typeof entry !== "object" || Array.isArray(entry)) {
312
+ return {
313
+ ok: false,
314
+ error: `claims.${section}.${name} must be null or a JSON object`,
315
+ }
316
+ }
317
+ const e = entry as Record<string, unknown>
318
+ const normalized: {
319
+ essential?: boolean
320
+ value?: unknown
321
+ values?: unknown[]
322
+ } = {}
323
+ if (typeof e.essential === "boolean") normalized.essential = e.essential
324
+ if ("value" in e) normalized.value = e.value
325
+ if (Array.isArray(e.values)) normalized.values = e.values
326
+ sectionMap[name] = normalized
327
+ }
328
+ result[section] = sectionMap
329
+ }
330
+ return { ok: true, value: result }
331
+ }
332
+
192
333
  function clearFlowCookie() {
193
334
  return {
194
335
  name: "idp.flow",