@_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
package/src/domain/token.ts
CHANGED
|
@@ -47,7 +47,8 @@ import {
|
|
|
47
47
|
sha256,
|
|
48
48
|
utf8,
|
|
49
49
|
} from "./crypto"
|
|
50
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
package/src/domain/userinfo.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/userinfo` endpoint logic (OIDC Core §5.3). Verifies the
|
|
3
|
-
* token
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/http/context.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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",
|