@_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
@@ -0,0 +1,293 @@
1
+ /**
2
+ * DPoP — Demonstration of Proof-of-Possession (RFC 9449).
3
+ *
4
+ * The RP/client generates an asymmetric key pair, sends a fresh proof
5
+ * JWT with each authenticated request, and the IdP binds the issued
6
+ * access token to the public key's SHA-256 thumbprint (`cnf.jkt`).
7
+ *
8
+ * This module parses + verifies DPoP proof JWTs. Replay protection is
9
+ * delegated to `TokenStore.recordDpopJti`; adapters without that method
10
+ * cannot satisfy DPoP and the verifier returns `invalid_dpop_proof`.
11
+ *
12
+ * Per RFC 9449 §4.2/§4.3, the proof JWT carries:
13
+ * - header: `typ: "dpop+jwt"`, `alg: <asymmetric>`, `jwk: <pub-JWK>`
14
+ * - payload: `htu`, `htm`, `iat`, `jti`, optional `ath`, optional `nonce`
15
+ *
16
+ * Verification chain (§4.3):
17
+ * 1. Header `typ` is exactly `"dpop+jwt"`.
18
+ * 2. `alg` is in the asymmetric allow-list (`ES256`, `EdDSA`).
19
+ * 3. `jwk` is a public key in JWK form; signature verifies under it.
20
+ * 4. `htm` equals the request method (case-sensitive).
21
+ * 5. `htu` equals the request URI (scheme + host + path; query / frag stripped).
22
+ * 6. `iat` is within ±`iatToleranceSec` of `now`.
23
+ * 7. `jti` has not been seen within the replay window (TokenStore-tracked).
24
+ * 8. If `expectedAth` supplied (resource-server check), `ath` matches.
25
+ *
26
+ * The thumbprint (`jkt`) is computed via RFC 7638 over the public JWK.
27
+ */
28
+ import type { JWK, KeyLike } from "jose"
29
+ import {
30
+ calculateJwkThumbprint,
31
+ decodeProtectedHeader,
32
+ importJWK,
33
+ jwtVerify,
34
+ } from "jose"
35
+
36
+ import type { TokenStore } from "../ports/token-store"
37
+ import { authError, type AuthError } from "../types/error"
38
+ import type { Result } from "../types/result"
39
+ import { err, isErr, ok } from "../types/result"
40
+
41
+ import { base64url, sha256, utf8 } from "./crypto"
42
+
43
+ /** Allow-list of `alg` values accepted in DPoP proofs. Asymmetric only. */
44
+ const DPOP_ASYMMETRIC_ALGS: ReadonlySet<string> = new Set(["ES256", "EdDSA"])
45
+
46
+ /** RFC 9449 §4.2 — fixed 60-second tolerance is the conventional default. */
47
+ export const DEFAULT_DPOP_IAT_TOLERANCE_SEC = 60
48
+
49
+ /** Replay-window TTL for `jti` tracking. Matches `iat` tolerance × 2. */
50
+ export const DEFAULT_DPOP_JTI_TTL_MS = 2 * DEFAULT_DPOP_IAT_TOLERANCE_SEC * 1000
51
+
52
+ export type DpopProofPayload = {
53
+ htu: string
54
+ htm: string
55
+ iat: number
56
+ jti: string
57
+ ath?: string
58
+ nonce?: string
59
+ }
60
+
61
+ export type VerifiedDpopProof = {
62
+ /** RFC 7638 JWK thumbprint of the proof's public key. */
63
+ jkt: string
64
+ /** The decoded + signature-verified proof payload. */
65
+ payload: DpopProofPayload
66
+ }
67
+
68
+ export type VerifyDpopProofInput = {
69
+ proofJwt: string
70
+ /** Actual request URI (scheme + host + port + path; query/frag stripped). */
71
+ htu: string
72
+ /** Actual HTTP method, uppercase. */
73
+ htm: string
74
+ /** Wall clock seconds. */
75
+ nowSec: number
76
+ /** Optional skew allowance. */
77
+ iatToleranceSec?: number
78
+ /**
79
+ * Required at resource servers when the proof must bind to a presented
80
+ * access token. The verifier checks the proof's `ath` equals
81
+ * base64url(SHA-256(access_token ASCII)). Omit at the AS `/token`
82
+ * endpoint — no access token exists yet there.
83
+ */
84
+ expectedAth?: string
85
+ }
86
+
87
+ export type VerifyDpopProofDeps = {
88
+ tokenStore: TokenStore
89
+ jtiTtlMs?: number
90
+ }
91
+
92
+ /**
93
+ * Verify a DPoP proof JWT and return its `jkt` + payload. Side-effect:
94
+ * records the `jti` in `TokenStore` so a replay within the TTL window
95
+ * is rejected.
96
+ */
97
+ export async function verifyDpopProof(
98
+ input: VerifyDpopProofInput,
99
+ deps: VerifyDpopProofDeps,
100
+ ): Promise<Result<VerifiedDpopProof, AuthError>> {
101
+ if (!deps.tokenStore.recordDpopJti) {
102
+ return err(
103
+ authError.invalidDpopProof(
104
+ "token-store adapter does not support DPoP replay protection",
105
+ ),
106
+ )
107
+ }
108
+ // 1. Decode the header to recover the embedded JWK + check `typ`/`alg`
109
+ // before the signature verification call so we can return precise
110
+ // errors instead of a generic jose failure.
111
+ let header: ReturnType<typeof decodeProtectedHeader>
112
+ try {
113
+ header = decodeProtectedHeader(input.proofJwt)
114
+ } catch (e) {
115
+ return err(
116
+ authError.invalidDpopProof(
117
+ `dpop proof header is not a valid compact JWS: ${stringifyError(e)}`,
118
+ ),
119
+ )
120
+ }
121
+ if (header.typ !== "dpop+jwt") {
122
+ return err(
123
+ authError.invalidDpopProof(
124
+ `dpop proof header.typ must be "dpop+jwt", got "${String(header.typ)}"`,
125
+ ),
126
+ )
127
+ }
128
+ if (!header.alg || !DPOP_ASYMMETRIC_ALGS.has(header.alg)) {
129
+ return err(
130
+ authError.invalidDpopProof(
131
+ `dpop proof alg "${String(header.alg)}" not in {ES256, EdDSA}`,
132
+ ),
133
+ )
134
+ }
135
+ const jwk = header.jwk as JWK | undefined
136
+ if (!jwk || typeof jwk !== "object") {
137
+ return err(
138
+ authError.invalidDpopProof(
139
+ "dpop proof header.jwk missing or not an object",
140
+ ),
141
+ )
142
+ }
143
+ // §4.2: the embedded JWK MUST be a public key. Reject anything that
144
+ // looks like a private key (`d` for EC / OKP, `d`+`p`+`q` for RSA).
145
+ if ("d" in jwk) {
146
+ return err(
147
+ authError.invalidDpopProof(
148
+ "dpop proof header.jwk must be a public key (private material present)",
149
+ ),
150
+ )
151
+ }
152
+
153
+ // 2. Verify the signature with the embedded JWK.
154
+ let key: KeyLike
155
+ try {
156
+ key = (await importJWK(jwk, header.alg)) as KeyLike
157
+ } catch (e) {
158
+ return err(
159
+ authError.invalidDpopProof(
160
+ `dpop proof JWK import failed: ${stringifyError(e)}`,
161
+ ),
162
+ )
163
+ }
164
+ let verified
165
+ try {
166
+ verified = await jwtVerify(input.proofJwt, key, {
167
+ algorithms: [header.alg],
168
+ })
169
+ } catch (e) {
170
+ return err(
171
+ authError.invalidDpopProof(
172
+ `dpop proof signature verification failed: ${stringifyError(e)}`,
173
+ ),
174
+ )
175
+ }
176
+ const payload = verified.payload as Partial<DpopProofPayload>
177
+
178
+ // 3. Payload-shape checks. The compact set: htu, htm, iat, jti.
179
+ if (typeof payload.htm !== "string") {
180
+ return err(authError.invalidDpopProof("dpop proof missing string htm"))
181
+ }
182
+ if (typeof payload.htu !== "string") {
183
+ return err(authError.invalidDpopProof("dpop proof missing string htu"))
184
+ }
185
+ if (typeof payload.iat !== "number") {
186
+ return err(authError.invalidDpopProof("dpop proof missing numeric iat"))
187
+ }
188
+ if (typeof payload.jti !== "string" || payload.jti.length === 0) {
189
+ return err(
190
+ authError.invalidDpopProof("dpop proof missing non-empty string jti"),
191
+ )
192
+ }
193
+
194
+ // 4. htm / htu equality.
195
+ if (payload.htm !== input.htm) {
196
+ return err(
197
+ authError.invalidDpopProof(
198
+ `dpop htm "${payload.htm}" does not match request method "${input.htm}"`,
199
+ ),
200
+ )
201
+ }
202
+ if (payload.htu !== input.htu) {
203
+ return err(
204
+ authError.invalidDpopProof(
205
+ `dpop htu "${payload.htu}" does not match request uri "${input.htu}"`,
206
+ ),
207
+ )
208
+ }
209
+
210
+ // 5. iat freshness.
211
+ const tolerance = input.iatToleranceSec ?? DEFAULT_DPOP_IAT_TOLERANCE_SEC
212
+ const skew = Math.abs(input.nowSec - payload.iat)
213
+ if (skew > tolerance) {
214
+ return err(
215
+ authError.invalidDpopProof(
216
+ `dpop iat skew ${skew}s exceeds tolerance ${tolerance}s`,
217
+ ),
218
+ )
219
+ }
220
+
221
+ // 6. ath check (RS use only).
222
+ if (input.expectedAth !== undefined) {
223
+ if (typeof payload.ath !== "string" || payload.ath !== input.expectedAth) {
224
+ return err(
225
+ authError.invalidDpopProof(
226
+ "dpop ath does not match SHA-256 of the access token",
227
+ ),
228
+ )
229
+ }
230
+ }
231
+
232
+ // 7. jti replay protection — atomic record-or-fail in the token store.
233
+ const recorded = await deps.tokenStore.recordDpopJti(
234
+ payload.jti,
235
+ deps.jtiTtlMs ?? DEFAULT_DPOP_JTI_TTL_MS,
236
+ )
237
+ if (isErr(recorded)) {
238
+ return err(
239
+ authError.invalidDpopProof(`dpop jti "${payload.jti}" replayed`, {
240
+ jti: payload.jti,
241
+ }),
242
+ )
243
+ }
244
+
245
+ // 8. Compute jkt thumbprint (RFC 7638) — bound onto the issued token.
246
+ let jkt: string
247
+ try {
248
+ jkt = await calculateJwkThumbprint(jwk, "sha256")
249
+ } catch (e) {
250
+ return err(
251
+ authError.invalidDpopProof(
252
+ `dpop jkt thumbprint computation failed: ${stringifyError(e)}`,
253
+ ),
254
+ )
255
+ }
256
+
257
+ return ok({
258
+ jkt,
259
+ payload: {
260
+ htm: payload.htm,
261
+ htu: payload.htu,
262
+ iat: payload.iat,
263
+ jti: payload.jti,
264
+ ...(payload.ath !== undefined ? { ath: payload.ath } : {}),
265
+ ...(payload.nonce !== undefined ? { nonce: payload.nonce } : {}),
266
+ },
267
+ })
268
+ }
269
+
270
+ /**
271
+ * Canonicalize a request URL for `htu` comparison (RFC 9449 §4.3).
272
+ * Strips query string and fragment; preserves scheme, host, port, path.
273
+ * Default port for the scheme is removed.
274
+ */
275
+ export function canonicalHtu(rawUrl: string): string {
276
+ const u = new URL(rawUrl)
277
+ u.search = ""
278
+ u.hash = ""
279
+ // Normalize default ports per the URL spec — `new URL("https://x:443").port` is "".
280
+ return u.toString()
281
+ }
282
+
283
+ /**
284
+ * Compute the `ath` value (RFC 9449 §4.2): base64url(SHA-256(access_token ASCII)).
285
+ * Resource servers compare the presented DPoP proof's `ath` to this.
286
+ */
287
+ export async function computeAth(accessToken: string): Promise<string> {
288
+ return base64url.encode(await sha256(utf8.encode(accessToken)))
289
+ }
290
+
291
+ function stringifyError(e: unknown): string {
292
+ return e instanceof Error ? e.message : String(e)
293
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `id_token` assembly — claim selection, scope gating, `at_hash`, `amr`
3
+ * derivation.
4
+ *
5
+ * OIDC Core §2 mandates `iss`, `sub`, `aud`, `exp`, `iat`. `auth_time` is
6
+ * required when `max_age` was requested or `auth_time` is essential; we
7
+ * always include it because we always know it (stamped at `success`).
8
+ * `nonce` is required to echo the RP's `/authorize` value when present.
9
+ *
10
+ * Scope→claim mapping follows OIDC Core §5.4 exactly. Properties are
11
+ * sourced from `SubjectClaim.properties` — hosts populate that record in
12
+ * their `IdPOptions.success` callback. We only emit a profile claim
13
+ * when (a) the corresponding scope was granted and (b) the value is a
14
+ * recognized JSON-serializable type. Unknown values are silently
15
+ * dropped (forward compat with subject schemas the host adds later).
16
+ *
17
+ * `at_hash` is computed per §3.1.3.6: left-half SHA-256 of the access
18
+ * token's ASCII octets, base64url-encoded with no padding.
19
+ *
20
+ * `amr` (RFC 8176) is derived from `methodKind` via a small fixed table.
21
+ * Methods that aren't in the table simply omit the claim — `amr` is
22
+ * optional. Hosts that want richer amr semantics should add the claim
23
+ * via the host claim hook (Phase E).
24
+ */
25
+ import { base64url, sha256, utf8 } from "./crypto"
26
+ import type { ClaimsRequest } from "../types/authorization"
27
+ import type { IdTokenClaims, ScopedProfileClaims } from "../types/token"
28
+ import type { SubjectClaim } from "../types/subject"
29
+
30
+ /**
31
+ * Map of OIDC scope name → list of profile claim names that scope grants
32
+ * (OIDC Core §5.4). `openid` itself is the discriminator that an id_token
33
+ * should be issued at all — it grants no profile claims.
34
+ */
35
+ const SCOPE_TO_CLAIMS: Record<string, ReadonlyArray<string>> = {
36
+ profile: [
37
+ "name",
38
+ "given_name",
39
+ "family_name",
40
+ "middle_name",
41
+ "nickname",
42
+ "preferred_username",
43
+ "profile",
44
+ "picture",
45
+ "website",
46
+ "gender",
47
+ "birthdate",
48
+ "zoneinfo",
49
+ "locale",
50
+ "updated_at",
51
+ ],
52
+ email: ["email", "email_verified"],
53
+ phone: ["phone_number", "phone_number_verified"],
54
+ address: ["address"],
55
+ }
56
+
57
+ /**
58
+ * Derive `amr` from the originating `methodKind`. Values are RFC 8176
59
+ * registered AMR codes. Returns `undefined` when no clean mapping exists
60
+ * — `amr` is an OPTIONAL id_token claim, so omission is spec-compliant.
61
+ */
62
+ export function deriveAmr(methodKind: string): string[] | undefined {
63
+ switch (methodKind) {
64
+ case "password":
65
+ return ["pwd"]
66
+ case "code":
67
+ return ["otp"]
68
+ case "passkey":
69
+ // Resident credentials backed by a platform authenticator. Best
70
+ // approximated by `hwk` (hardware key) per RFC 8176; platform
71
+ // authenticators are usually hardware-backed (Secure Enclave,
72
+ // TPM, etc.) even when the key is software-resident on disk.
73
+ return ["hwk"]
74
+ default:
75
+ // External federated providers (google, github, oidc-generic, …)
76
+ // — no standardized AMR for "logged in elsewhere", so omit.
77
+ return undefined
78
+ }
79
+ }
80
+
81
+ /**
82
+ * `at_hash` per OIDC Core §3.1.3.6: left-half of SHA-256(access_token
83
+ * ASCII), base64url. Always 16 bytes → 22 base64url characters for
84
+ * SHA-256 (256-bit → 128-bit half → 16 bytes → 22 chars).
85
+ */
86
+ export async function computeAtHash(accessToken: string): Promise<string> {
87
+ const digest = await sha256(utf8.encode(accessToken))
88
+ const half = digest.slice(0, digest.byteLength / 2)
89
+ return base64url.encode(half)
90
+ }
91
+
92
+ /**
93
+ * Filter a `SubjectClaim.properties` record down to claims granted by the
94
+ * requested OIDC scopes, then optionally augment with claim names the RP
95
+ * specifically requested via the OIDC Core §5.5 `claims` parameter.
96
+ * Returns a typed `ScopedProfileClaims` ready to spread into the id_token
97
+ * or `/userinfo` response. Properties not named by any granted scope or
98
+ * explicit claim request are dropped.
99
+ *
100
+ * `extra` is the set of claim names the RP requested via `claims`
101
+ * parameter for THIS surface (id_token vs userinfo). Listing a name in
102
+ * `extra` bypasses scope gating per §5.5 ("works without the requestor
103
+ * having to include the scope value").
104
+ *
105
+ * `customMappings` is a host-supplied scope → claim-names map merged on
106
+ * top of the OIDC Core §5.4 table. Lets the host expose vendor-specific
107
+ * identity fields (e.g. `tenant_id`, `org_role`) via a custom scope
108
+ * (`scope=foo` granting `[tenant_id, …]`). Reserved §5.4 names cannot be
109
+ * shadowed — the standard mapping always wins on collision so an
110
+ * `email` scope can't accidentally start meaning something else.
111
+ *
112
+ * Host-supplied values are trusted to match their declared OIDC types
113
+ * — schemas live on `IdPOptions.subjects`, not here. The narrowing cast
114
+ * is the boundary between "host's responsibility" and "framework's
115
+ * responsibility."
116
+ */
117
+ export function pickScopedClaims(
118
+ claim: SubjectClaim,
119
+ scopes: ReadonlyArray<string>,
120
+ extra: ReadonlyArray<string> = [],
121
+ customMappings: Record<string, ReadonlyArray<string>> = {},
122
+ ): ScopedProfileClaims {
123
+ const props = (claim as { properties: Record<string, unknown> }).properties
124
+ if (!props || typeof props !== "object") return {}
125
+ // Standard §5.4 names take precedence: spread custom first, then
126
+ // SCOPE_TO_CLAIMS, so a host can't override `email → [email, ...]`.
127
+ const mappings: Record<string, ReadonlyArray<string>> = {
128
+ ...customMappings,
129
+ ...SCOPE_TO_CLAIMS,
130
+ }
131
+ const granted = new Set<string>()
132
+ for (const scope of scopes) {
133
+ const list = mappings[scope]
134
+ if (!list) continue
135
+ for (const c of list) granted.add(c)
136
+ }
137
+ for (const name of extra) granted.add(name)
138
+ const out: Record<string, unknown> = {}
139
+ for (const key of granted) {
140
+ if (key in props && props[key] !== undefined) out[key] = props[key]
141
+ }
142
+ return out as ScopedProfileClaims
143
+ }
144
+
145
+ export type BuildIdTokenInput = {
146
+ issuerUrl: string
147
+ /** RP `client_id` — the id_token's `aud`. */
148
+ audience: string
149
+ /** Stable subject identifier (same value as the access token's `sub`). */
150
+ subjectId: string
151
+ claim: SubjectClaim
152
+ scopes: ReadonlyArray<string>
153
+ /** Seconds-since-epoch the user authenticated. */
154
+ authTime: number
155
+ /** RP-supplied OIDC nonce, when present. */
156
+ appNonce?: string
157
+ /** Wall clock (ms). */
158
+ now: number
159
+ /** id_token TTL (ms). Default 5 minutes. */
160
+ ttlMs?: number
161
+ /** Method kind for `amr` derivation. */
162
+ methodKind: string
163
+ /** Pre-signed access token whose `at_hash` is bound into the id_token. */
164
+ accessToken: string
165
+ /** OIDC Core §5.5 — RP-requested claims (this drives only id_token here). */
166
+ claimsRequest?: ClaimsRequest
167
+ /**
168
+ * Host-supplied vendor scope → claim-names map. Merged on top of OIDC
169
+ * Core §5.4 at scope-gating time. See `pickScopedClaims`.
170
+ */
171
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
172
+ }
173
+
174
+ export const DEFAULT_ID_TOKEN_TTL_MS = 5 * 60 * 1000
175
+
176
+ /**
177
+ * Assemble an `IdTokenClaims` object. Pure — no crypto except `at_hash`.
178
+ * Caller signs the result via `signIdToken`.
179
+ *
180
+ * Composition is via spread so the type stays narrow: the base claim set
181
+ * (REQUIRED OIDC §2 + auth_time + at_hash + optional nonce/amr) is built
182
+ * first; the scope-gated §5.1 profile claims merge over the top via
183
+ * `...pickScopedClaims(...)`. No dynamic index assignment, no cast.
184
+ */
185
+ export async function buildIdTokenClaims(
186
+ input: BuildIdTokenInput,
187
+ ): Promise<IdTokenClaims> {
188
+ const ttl = input.ttlMs ?? DEFAULT_ID_TOKEN_TTL_MS
189
+ const amr = deriveAmr(input.methodKind)
190
+ const extraClaims = Object.keys(input.claimsRequest?.id_token ?? {})
191
+ return {
192
+ iss: input.issuerUrl,
193
+ sub: input.subjectId,
194
+ aud: input.audience,
195
+ exp: Math.floor((input.now + ttl) / 1000),
196
+ iat: Math.floor(input.now / 1000),
197
+ auth_time: input.authTime,
198
+ at_hash: await computeAtHash(input.accessToken),
199
+ ...(input.appNonce !== undefined ? { nonce: input.appNonce } : {}),
200
+ ...(amr ? { amr } : {}),
201
+ ...pickScopedClaims(
202
+ input.claim,
203
+ input.scopes,
204
+ extraClaims,
205
+ input.customScopeClaims ?? {},
206
+ ),
207
+ }
208
+ }
209
+
210
+ /** Predicate: does this token request warrant an `id_token` being issued? */
211
+ export function shouldIssueIdToken(scopes: ReadonlyArray<string>): boolean {
212
+ return scopes.includes("openid")
213
+ }
@@ -50,9 +50,15 @@ export type IntrospectResponse =
50
50
  iat: number
51
51
  scope?: string
52
52
  client_id?: string
53
+ /** RFC 7662 §2.2 — the token's type indicator. */
54
+ token_type: "Bearer" | "DPoP"
53
55
  tid: string
54
56
  mid?: string
55
57
  mkind?: string
58
+ /** Library-specific — subject schema discriminator (e.g. "user", "admin"). */
59
+ subject_type?: string
60
+ /** RFC 9449 §6 — present when the access token is DPoP-bound. */
61
+ cnf?: { jkt: string }
56
62
  }
57
63
 
58
64
  export type IntrospectRequest = {
@@ -129,6 +135,7 @@ export async function introspect(
129
135
  return ok({ active: false })
130
136
  }
131
137
 
138
+ const subjectType = (claims.claim as { type?: string } | undefined)?.type
132
139
  return ok({
133
140
  active: true,
134
141
  sub: claims.sub,
@@ -138,8 +145,11 @@ export async function introspect(
138
145
  iat: claims.iat,
139
146
  scope: claims.scope,
140
147
  client_id: claims.aud,
148
+ token_type: claims.cnf?.jkt !== undefined ? "DPoP" : "Bearer",
141
149
  tid: claims.tid,
142
150
  mid: claims.mid,
143
151
  mkind: claims.mkind,
152
+ ...(subjectType !== undefined ? { subject_type: subjectType } : {}),
153
+ ...(claims.cnf !== undefined ? { cnf: claims.cnf } : {}),
144
154
  })
145
155
  }
package/src/domain/jwt.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import type { JWK, KeyLike } from "jose"
10
10
  import { importJWK, jwtVerify, SignJWT } from "jose"
11
11
 
12
- import type { AccessTokenClaims } from "../types/token"
12
+ import type { AccessTokenClaims, IdTokenClaims } from "../types/token"
13
13
  import type { SigningKey } from "../ports/key-store"
14
14
 
15
15
  /**
@@ -28,6 +28,85 @@ export async function signAccessToken(
28
28
  .sign(privateKey)
29
29
  }
30
30
 
31
+ /**
32
+ * Sign an OIDC `id_token` claim set (OIDC Core §2). Header `typ` is
33
+ * `"JWT"` per common practice; OIDC Core does not mandate `"id_token"`.
34
+ * The claims object is verified by the caller to include `iss`, `sub`,
35
+ * `aud`, `exp`, `iat` (REQUIRED per §2).
36
+ */
37
+ export async function signIdToken(
38
+ claims: IdTokenClaims,
39
+ privateKey: KeyLike,
40
+ alg: string,
41
+ kid: string,
42
+ ): Promise<string> {
43
+ return new SignJWT(claims as unknown as Record<string, unknown>)
44
+ .setProtectedHeader({ alg, typ: "JWT", kid })
45
+ .sign(privateKey)
46
+ }
47
+
48
+ /**
49
+ * Verify an OIDC `id_token` against the IdP's published signing keys.
50
+ * Used at `/end_session` to validate `id_token_hint` and by adjacent
51
+ * domain code that needs to introspect a previously-issued id_token.
52
+ *
53
+ * Same algorithm-confusion defenses as `verifyAccessToken`: only the
54
+ * asymmetric allow-list (`ES256`, `EdDSA`) accepted; `alg: "none"`
55
+ * rejected explicitly.
56
+ *
57
+ * `acceptExpired` relaxes the `exp` check — required at `/end_session`
58
+ * per OIDC RP-Initiated Logout 1.0 §2, where logout often follows token
59
+ * expiry and the spec permits accepting an expired hint. The signature
60
+ * + issuer + audience checks remain strict.
61
+ */
62
+ export async function verifyIdToken(
63
+ token: string,
64
+ keys: ReadonlyArray<SigningKey>,
65
+ options: {
66
+ issuer?: string
67
+ audience?: string
68
+ acceptExpired?: boolean
69
+ } = {},
70
+ ): Promise<IdTokenClaims> {
71
+ const algorithms = Array.from(
72
+ new Set(keys.map((k) => k.alg).filter((a) => ASYMMETRIC_ALGS.has(a))),
73
+ )
74
+ const { payload } = await jwtVerify<IdTokenClaims>(
75
+ token,
76
+ async (header) => {
77
+ if (!header.alg || header.alg === "none") {
78
+ throw new Error(`verifyIdToken: refusing alg "${header.alg ?? ""}"`)
79
+ }
80
+ const match = keys.find((k) => k.kid === header.kid)
81
+ if (!match) {
82
+ throw new Error(`verifyIdToken: unknown kid "${header.kid}"`)
83
+ }
84
+ if (match.alg !== header.alg) {
85
+ throw new Error(
86
+ `verifyIdToken: header.alg "${header.alg}" does not match key alg "${match.alg}"`,
87
+ )
88
+ }
89
+ const imported = await importJWK(
90
+ match.publicJwk as unknown as JWK,
91
+ match.alg,
92
+ )
93
+ return imported as KeyLike
94
+ },
95
+ {
96
+ algorithms,
97
+ ...(options.issuer ? { issuer: options.issuer } : {}),
98
+ ...(options.audience ? { audience: options.audience } : {}),
99
+ // jose checks `exp` against `currentDate + clockTolerance`. A huge
100
+ // tolerance effectively disables the expiry check while preserving
101
+ // signature + issuer + audience validation.
102
+ ...(options.acceptExpired
103
+ ? { clockTolerance: Number.MAX_SAFE_INTEGER }
104
+ : {}),
105
+ },
106
+ )
107
+ return payload
108
+ }
109
+
31
110
  /** Allow-list of asymmetric `alg` values the IdP issues + accepts. */
32
111
  const ASYMMETRIC_ALGS: ReadonlySet<string> = new Set(["ES256", "EdDSA"])
33
112