@_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.
- package/dist/esm/adapters/memory/session-store.js +22 -0
- package/dist/esm/adapters/memory/token-store.js +13 -0
- package/dist/esm/client.js +9 -2
- package/dist/esm/domain/authorize.js +5 -0
- package/dist/esm/domain/callback.js +3 -0
- package/dist/esm/domain/discovery.js +44 -2
- package/dist/esm/domain/dpop.js +122 -0
- package/dist/esm/domain/id-token.js +94 -0
- package/dist/esm/domain/introspect.js +5 -1
- package/dist/esm/domain/jwt.js +28 -0
- package/dist/esm/domain/logout.js +75 -0
- package/dist/esm/domain/method-route.js +3 -0
- package/dist/esm/domain/par.js +51 -0
- package/dist/esm/domain/refresh.js +12 -1
- package/dist/esm/domain/register.js +74 -0
- package/dist/esm/domain/token-exchange.js +1 -0
- package/dist/esm/domain/token.js +48 -7
- package/dist/esm/domain/userinfo.js +38 -3
- package/dist/esm/http/handlers/authorize.js +90 -3
- package/dist/esm/http/handlers/end-session.js +64 -0
- package/dist/esm/http/handlers/metadata.js +5 -2
- package/dist/esm/http/handlers/par.js +53 -0
- package/dist/esm/http/handlers/register.js +46 -0
- package/dist/esm/http/handlers/token.js +36 -6
- package/dist/esm/http/handlers/userinfo.js +35 -29
- package/dist/esm/http/router.js +10 -0
- package/dist/esm/http/schemas/authorize.js +7 -1
- package/dist/esm/http/schemas/end-session.js +13 -0
- package/dist/esm/http/schemas/par.js +9 -0
- package/dist/esm/http/schemas/register.js +16 -0
- package/dist/esm/index.js +7 -2
- package/dist/esm/types/error.js +5 -0
- package/dist/types/adapters/memory/session-store.d.ts +3 -1
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/token-store.d.ts +1 -0
- package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
- package/dist/types/client.d.ts +19 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/domain/authorize.d.ts.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +2 -0
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/discovery.d.ts +37 -0
- package/dist/types/domain/discovery.d.ts.map +1 -1
- package/dist/types/domain/dpop.d.ts +61 -0
- package/dist/types/domain/dpop.d.ts.map +1 -0
- package/dist/types/domain/id-token.d.ts +83 -0
- package/dist/types/domain/id-token.d.ts.map +1 -0
- package/dist/types/domain/introspect.d.ts +8 -0
- package/dist/types/domain/introspect.d.ts.map +1 -1
- package/dist/types/domain/jwt.d.ts +27 -1
- package/dist/types/domain/jwt.d.ts.map +1 -1
- package/dist/types/domain/logout.d.ts +69 -0
- package/dist/types/domain/logout.d.ts.map +1 -0
- package/dist/types/domain/par.d.ts +62 -0
- package/dist/types/domain/par.d.ts.map +1 -0
- package/dist/types/domain/refresh.d.ts +9 -0
- package/dist/types/domain/refresh.d.ts.map +1 -1
- package/dist/types/domain/register.d.ts +33 -0
- package/dist/types/domain/register.d.ts.map +1 -0
- package/dist/types/domain/token-exchange.d.ts +2 -0
- package/dist/types/domain/token-exchange.d.ts.map +1 -1
- package/dist/types/domain/token.d.ts +41 -1
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/domain/userinfo.d.ts +44 -5
- package/dist/types/domain/userinfo.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +4 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/authorize.d.ts.map +1 -1
- package/dist/types/http/handlers/end-session.d.ts +3 -0
- package/dist/types/http/handlers/end-session.d.ts.map +1 -0
- package/dist/types/http/handlers/metadata.d.ts +1 -1
- package/dist/types/http/handlers/metadata.d.ts.map +1 -1
- package/dist/types/http/handlers/par.d.ts +3 -0
- package/dist/types/http/handlers/par.d.ts.map +1 -0
- package/dist/types/http/handlers/register.d.ts +3 -0
- package/dist/types/http/handlers/register.d.ts.map +1 -0
- package/dist/types/http/handlers/token.d.ts.map +1 -1
- package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/http/schemas/authorize.d.ts +40 -0
- package/dist/types/http/schemas/authorize.d.ts.map +1 -1
- package/dist/types/http/schemas/end-session.d.ts +31 -0
- package/dist/types/http/schemas/end-session.d.ts.map +1 -0
- package/dist/types/http/schemas/par.d.ts +20 -0
- package/dist/types/http/schemas/par.d.ts.map +1 -0
- package/dist/types/http/schemas/register.d.ts +42 -0
- package/dist/types/http/schemas/register.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/ports/audit-log.d.ts +27 -0
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +27 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/ports/token-store.d.ts +12 -0
- package/dist/types/ports/token-store.d.ts.map +1 -1
- package/dist/types/types/authorization.d.ts +23 -0
- package/dist/types/types/authorization.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +9 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/flow.d.ts +10 -0
- package/dist/types/types/flow.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +91 -1
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/tenant.d.ts +37 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/dist/types/types/token.d.ts +128 -0
- package/dist/types/types/token.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/memory/session-store.ts +44 -1
- package/src/adapters/memory/token-store.ts +16 -0
- package/src/client.ts +32 -0
- package/src/domain/authorize.ts +9 -0
- package/src/domain/callback.ts +5 -0
- package/src/domain/client-credentials.ts +2 -0
- package/src/domain/discovery.ts +80 -2
- package/src/domain/dpop.ts +293 -0
- package/src/domain/id-token.ts +213 -0
- package/src/domain/introspect.ts +10 -0
- package/src/domain/jwt.ts +80 -1
- package/src/domain/logout.ts +180 -0
- package/src/domain/method-route.ts +5 -0
- package/src/domain/par.ts +141 -0
- package/src/domain/refresh.ts +41 -0
- package/src/domain/register.ts +168 -0
- package/src/domain/token-exchange.ts +11 -0
- package/src/domain/token.ts +134 -8
- package/src/domain/userinfo.ts +121 -6
- package/src/http/context.ts +4 -0
- package/src/http/handlers/authorize.ts +144 -3
- package/src/http/handlers/end-session.ts +106 -0
- package/src/http/handlers/metadata.ts +7 -2
- package/src/http/handlers/par.ts +84 -0
- package/src/http/handlers/register.ts +70 -0
- package/src/http/handlers/token.ts +56 -0
- package/src/http/handlers/userinfo.ts +62 -38
- package/src/http/router.ts +19 -0
- package/src/http/schemas/authorize.ts +27 -0
- package/src/http/schemas/end-session.ts +23 -0
- package/src/http/schemas/par.ts +23 -0
- package/src/http/schemas/register.ts +29 -0
- package/src/index.ts +18 -1
- package/src/ports/CONSISTENCY.md +52 -21
- package/src/ports/audit-log.ts +29 -0
- package/src/ports/session-store.ts +33 -0
- package/src/ports/token-store.ts +13 -0
- package/src/types/authorization.ts +25 -0
- package/src/types/error.ts +23 -0
- package/src/types/flow.ts +10 -0
- package/src/types/idp.ts +96 -0
- package/src/types/tenant.ts +37 -0
- 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
|
+
}
|
package/src/domain/introspect.ts
CHANGED
|
@@ -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
|
|