@web-ts-toolkit/express-oidc-vault 0.47.1 → 0.49.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 (6) hide show
  1. package/README.md +994 -75
  2. package/index.d.mts +423 -32
  3. package/index.d.ts +423 -32
  4. package/index.js +1972 -390
  5. package/index.mjs +1965 -384
  6. package/package.json +5 -3
package/index.js CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT: () => DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
38
38
  OIDC_VAULT_ROUTE_PATHS: () => OIDC_VAULT_ROUTE_PATHS,
39
39
  OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT: () => OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
40
+ OidcVaultDpopReplayCapacityError: () => OidcVaultDpopReplayCapacityError,
40
41
  OidcVaultStoreConflictError: () => OidcVaultStoreConflictError,
41
42
  createOidcVaultAccessTokenMiddleware: () => createOidcVaultAccessTokenMiddleware,
42
43
  createOidcVaultJwtAccessTokenValidator: () => createOidcVaultJwtAccessTokenValidator,
@@ -46,141 +47,9 @@ __export(index_exports, {
46
47
  resolveOidcVaultConfigFromEnv: () => resolveOidcVaultConfigFromEnv
47
48
  });
48
49
  module.exports = __toCommonJS(index_exports);
49
- var import_node_crypto = require("crypto");
50
+ var import_node_crypto6 = require("crypto");
50
51
  var import_express = __toESM(require("express"));
51
52
 
52
- // src/config.ts
53
- var DEFAULT_OIDC_SCOPES = "openid email profile";
54
- var MANUAL_CONFIG_REQUIREMENTS = ["issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri"];
55
- var MANUAL_CONFIG_SELECTORS = ["authorizationEndpoint", "tokenEndpoint", "jwksUri", "userInfoEndpoint", "endSessionEndpoint"];
56
- var normalizeOptionalString = (value) => {
57
- const trimmed = value?.trim();
58
- return trimmed ? trimmed : void 0;
59
- };
60
- var normalizeHttpUrl = (value, optionName) => {
61
- if (value === void 0) {
62
- return void 0;
63
- }
64
- let url;
65
- try {
66
- url = new URL(value);
67
- } catch {
68
- throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
69
- }
70
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
71
- throw new Error(`${optionName} must use http or https.`);
72
- }
73
- return url.toString();
74
- };
75
- var normalizeIssuerIdentifier = (value, optionName) => {
76
- if (value === void 0) {
77
- return void 0;
78
- }
79
- let url;
80
- try {
81
- url = new URL(value);
82
- } catch {
83
- throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
84
- }
85
- if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
86
- throw new Error(`${optionName} must use http or https.`);
87
- }
88
- if (url.username !== "" || url.password !== "") {
89
- throw new Error(`${optionName} must not include userinfo.`);
90
- }
91
- if (url.search !== "" || url.hash !== "") {
92
- throw new Error(`${optionName} must not include query or fragment.`);
93
- }
94
- return value;
95
- };
96
- var withOptionalValue = (target, key, value) => {
97
- if (value !== void 0) {
98
- target[key] = value;
99
- }
100
- };
101
- function resolveOidcVaultConfig(config = {}) {
102
- const issuer = normalizeIssuerIdentifier(normalizeOptionalString(config.issuer), "config.issuer");
103
- const clientId = normalizeOptionalString(config.clientId);
104
- const clientSecret = normalizeOptionalString(config.clientSecret);
105
- const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
106
- const hasManualEndpoints = MANUAL_CONFIG_SELECTORS.some((key) => normalizeOptionalString(config[key]));
107
- if (!clientId) {
108
- throw new Error("OIDC clientId is required.");
109
- }
110
- if (issuer && !hasManualEndpoints) {
111
- const resolved2 = {
112
- mode: "issuer",
113
- issuer,
114
- scopes
115
- };
116
- withOptionalValue(resolved2, "clientId", clientId);
117
- withOptionalValue(resolved2, "clientSecret", clientSecret);
118
- return resolved2;
119
- }
120
- const resolved = {
121
- mode: "manual",
122
- scopes
123
- };
124
- withOptionalValue(resolved, "issuer", issuer);
125
- withOptionalValue(
126
- resolved,
127
- "authorizationEndpoint",
128
- normalizeHttpUrl(normalizeOptionalString(config.authorizationEndpoint), "config.authorizationEndpoint")
129
- );
130
- withOptionalValue(
131
- resolved,
132
- "tokenEndpoint",
133
- normalizeHttpUrl(normalizeOptionalString(config.tokenEndpoint), "config.tokenEndpoint")
134
- );
135
- withOptionalValue(
136
- resolved,
137
- "userInfoEndpoint",
138
- normalizeHttpUrl(normalizeOptionalString(config.userInfoEndpoint), "config.userInfoEndpoint")
139
- );
140
- withOptionalValue(resolved, "jwksUri", normalizeHttpUrl(normalizeOptionalString(config.jwksUri), "config.jwksUri"));
141
- withOptionalValue(
142
- resolved,
143
- "endSessionEndpoint",
144
- normalizeHttpUrl(normalizeOptionalString(config.endSessionEndpoint), "config.endSessionEndpoint")
145
- );
146
- withOptionalValue(resolved, "clientId", clientId);
147
- withOptionalValue(resolved, "clientSecret", clientSecret);
148
- const missing = MANUAL_CONFIG_REQUIREMENTS.filter((key) => !resolved[key]);
149
- if (missing.length > 0) {
150
- throw new Error(`Missing required OIDC configuration for manual mode: ${missing.join(", ")}`);
151
- }
152
- return resolved;
153
- }
154
- function resolveOidcVaultConfigFromEnv(env = process.env) {
155
- return resolveOidcVaultConfig({
156
- issuer: env.OIDC_ISSUER,
157
- authorizationEndpoint: env.OIDC_AUTHORIZATION_ENDPOINT,
158
- tokenEndpoint: env.OIDC_TOKEN_ENDPOINT,
159
- userInfoEndpoint: env.OIDC_USERINFO_ENDPOINT,
160
- jwksUri: env.OIDC_JWKS_URI,
161
- endSessionEndpoint: env.OIDC_END_SESSION_ENDPOINT,
162
- clientId: env.OIDC_CLIENT_ID,
163
- clientSecret: env.OIDC_CLIENT_SECRET,
164
- scopes: env.OIDC_SCOPES
165
- });
166
- }
167
-
168
- // src/constants.ts
169
- var DEFAULT_OIDC_VAULT_BASE_PATH = "/auth/oidc";
170
- var DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
171
- var DEFAULT_EXCHANGE_CODE_TTL_MS = 30 * 1e3;
172
- var DEFAULT_SESSION_COOKIE_NAME = "oidc_vault_session";
173
- var DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT = "16kb";
174
- var OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT = 16;
175
- var OIDC_VAULT_ROUTE_PATHS = {
176
- login: "/login",
177
- callback: "/callback",
178
- exchange: "/exchange",
179
- refresh: "/refresh",
180
- logout: "/logout",
181
- "backchannel-logout": "/backchannel-logout"
182
- };
183
-
184
53
  // src/errors.ts
185
54
  var OidcVaultHttpError = class extends Error {
186
55
  constructor(status, code, message, clientMessage = message) {
@@ -260,6 +129,25 @@ var getRequiredFiniteNonNegativeInteger = (value, message, code, status = 502) =
260
129
  return value;
261
130
  };
262
131
 
132
+ // src/fingerprint-recognition.ts
133
+ var import_node_crypto3 = require("crypto");
134
+
135
+ // src/constants.ts
136
+ var DEFAULT_OIDC_VAULT_BASE_PATH = "/auth/oidc";
137
+ var DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS = 10 * 60 * 1e3;
138
+ var DEFAULT_EXCHANGE_CODE_TTL_MS = 30 * 1e3;
139
+ var DEFAULT_SESSION_COOKIE_NAME = "oidc_vault_session";
140
+ var DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT = "16kb";
141
+ var OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT = 16;
142
+ var OIDC_VAULT_ROUTE_PATHS = {
143
+ login: "/login",
144
+ callback: "/callback",
145
+ exchange: "/exchange",
146
+ refresh: "/refresh",
147
+ logout: "/logout",
148
+ "backchannel-logout": "/backchannel-logout"
149
+ };
150
+
263
151
  // src/cookies.ts
264
152
  var getSessionTransport = (options) => options.sessionTransport ?? "body";
265
153
  var usesCookieTransport = (options) => getSessionTransport(options) === "cookie";
@@ -290,6 +178,7 @@ var isHttpsBackendOrigin = (options) => {
290
178
  }
291
179
  };
292
180
  var COOKIE_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
181
+ var isValidCookieName = (value) => typeof value === "string" && COOKIE_NAME_PATTERN.test(value) && !hasUnsafeCookieValueCharacters(value);
293
182
  var COOKIE_DOMAIN_PATTERN = /^\.?[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
294
183
  var hasUnsafeCookieValueCharacters = (value) => {
295
184
  for (let index = 0; index < value.length; index += 1) {
@@ -305,7 +194,7 @@ var validateCookieOptions = (options) => {
305
194
  return;
306
195
  }
307
196
  const cookieOptions = resolveCookieOptions(options);
308
- if (!COOKIE_NAME_PATTERN.test(cookieOptions.name)) {
197
+ if (!isValidCookieName(cookieOptions.name)) {
309
198
  throw new Error("cookie.name must be a valid HTTP cookie name.");
310
199
  }
311
200
  if (hasUnsafeCookieValueCharacters(cookieOptions.path) || !cookieOptions.path.startsWith("/")) {
@@ -399,6 +288,527 @@ var parseSelectedCookieValue = (headerValue, selectedName) => {
399
288
  return void 0;
400
289
  };
401
290
 
291
+ // src/device-binding-policy.ts
292
+ var import_node_net = require("net");
293
+ var isPlainRecord = (value) => {
294
+ if (typeof value !== "object" || value === null) return false;
295
+ const prototype = Object.getPrototypeOf(value);
296
+ return prototype === Object.prototype || prototype === null;
297
+ };
298
+ var isDpopAlgorithm = (value) => value === "ES256" || value === "PS256" || value === "RS256";
299
+ var resolveInteger = (value, fallback, min, max, name) => {
300
+ const resolved = value === void 0 ? fallback : value;
301
+ if (typeof resolved !== "number" || !Number.isInteger(resolved) || resolved < min || resolved > max) {
302
+ throw new Error(`${name} must be an integer from ${min} through ${max}.`);
303
+ }
304
+ return resolved;
305
+ };
306
+ var resolveDeviceBindingOptions = (value) => {
307
+ if (value === void 0) return void 0;
308
+ if (!isPlainRecord(value)) throw new Error("deviceBinding must be a plain options object.");
309
+ const { mode, algorithms, proofMaxAgeSeconds, clockSkewSeconds, nonce } = value;
310
+ if (mode !== void 0 && mode !== "optional" && mode !== "required") {
311
+ throw new Error("deviceBinding.mode must be optional or required.");
312
+ }
313
+ const configuredAlgorithms = algorithms === void 0 ? ["ES256"] : algorithms;
314
+ if (!Array.isArray(configuredAlgorithms) || configuredAlgorithms.length === 0) {
315
+ throw new Error("deviceBinding.algorithms must be a nonempty allowlist of ES256, PS256, or RS256.");
316
+ }
317
+ const allowlist = Array.from(configuredAlgorithms);
318
+ if (!allowlist.every(isDpopAlgorithm)) {
319
+ throw new Error("deviceBinding.algorithms must be a nonempty allowlist of ES256, PS256, or RS256.");
320
+ }
321
+ let resolvedNonce = false;
322
+ if (nonce !== void 0 && nonce !== false) {
323
+ if (!isPlainRecord(nonce)) throw new Error("deviceBinding.nonce must be false or a plain nonce options object.");
324
+ const { secret, lifetimeSeconds } = nonce;
325
+ if (!(secret instanceof Uint8Array) || secret.byteLength < 32) {
326
+ throw new Error("deviceBinding.nonce.secret must be a Uint8Array of at least 32 bytes.");
327
+ }
328
+ resolvedNonce = Object.freeze({
329
+ // Uint8Array.from also copies Buffer/subarray inputs instead of retaining
330
+ // a shared view. Nonempty typed arrays cannot be Object.freeze'd; these
331
+ // owned bytes stay private while the plain nonce container is frozen.
332
+ secret: Uint8Array.from(secret),
333
+ lifetimeSeconds: resolveInteger(lifetimeSeconds, 60, 1, 300, "deviceBinding.nonce.lifetimeSeconds")
334
+ });
335
+ }
336
+ return Object.freeze({
337
+ mode: mode ?? "optional",
338
+ algorithms: Object.freeze([...new Set(allowlist)]),
339
+ proofMaxAgeSeconds: resolveInteger(proofMaxAgeSeconds, 60, 1, 300, "deviceBinding.proofMaxAgeSeconds"),
340
+ clockSkewSeconds: resolveInteger(clockSkewSeconds, 5, 0, 30, "deviceBinding.clockSkewSeconds"),
341
+ nonce: resolvedNonce
342
+ });
343
+ };
344
+ var DEVICE_BINDING_STORE_CAPABILITIES = [
345
+ "getAuthorizationTransaction",
346
+ "consumeAuthorizationTransactionIfMatches",
347
+ "getExchangeCode",
348
+ "consumeExchangeCodeIfMatches",
349
+ "getSessionRevocationContext",
350
+ "reserveDpopProof"
351
+ ];
352
+ function assertDeviceBindingStoreCapabilities(store, feature = "deviceBinding") {
353
+ for (const name of DEVICE_BINDING_STORE_CAPABILITIES) {
354
+ if (typeof store?.[name] !== "function") {
355
+ throw new Error(`${feature} requires storeProvider.${name}.`);
356
+ }
357
+ }
358
+ }
359
+ var resolveDeviceBindingOrigin = (value, name = "backendOrigin") => {
360
+ if (typeof value !== "string") throw new Error(`${name} must be a static HTTP(S) origin for deviceBinding.`);
361
+ const raw = value.trim();
362
+ let url;
363
+ try {
364
+ url = new URL(raw);
365
+ } catch {
366
+ throw new Error(`${name} must be a static HTTP(S) origin for deviceBinding.`);
367
+ }
368
+ if (!/^https?:\/\/[^/\\?#]+\/?$/i.test(raw) || /\s/.test(raw) || raw.includes("@") || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.origin === "null") {
369
+ throw new Error(
370
+ `${name} must be a static HTTP(S) origin without userinfo, path, query, or fragment for deviceBinding.`
371
+ );
372
+ }
373
+ const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || (0, import_node_net.isIP)(url.hostname) === 4 && url.hostname.startsWith("127.");
374
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
375
+ throw new Error(`${name} must use HTTPS except for loopback HTTP development when deviceBinding is enabled.`);
376
+ }
377
+ return url.origin;
378
+ };
379
+ var isCanonicalDpopJkt = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
380
+ var invalidBinding = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_DPOP_PROOF", diagnostic, "DPoP proof validation failed.");
381
+ var snapshotDpopBinding = (value) => {
382
+ if (value === void 0) return void 0;
383
+ if (!isPlainRecord(value)) throw invalidBinding("Stored device binding must be a plain dpop binding object.");
384
+ const { type, jkt } = value;
385
+ if (type !== "dpop" || !isCanonicalDpopJkt(jkt)) {
386
+ throw invalidBinding("Stored device binding must contain type dpop and a canonical SHA-256 JWK thumbprint.");
387
+ }
388
+ return Object.freeze({ type, jkt });
389
+ };
390
+ var assertDeviceBindingPolicy = (policy, binding) => {
391
+ const stored = snapshotDpopBinding(binding);
392
+ if (stored !== void 0 && policy === void 0) {
393
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
394
+ }
395
+ if (stored === void 0 && policy?.mode === "required") {
396
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
397
+ }
398
+ };
399
+ var resolveVerifiedDeviceBinding = (policy, binding, verified) => {
400
+ const stored = snapshotDpopBinding(binding);
401
+ assertDeviceBindingPolicy(policy, stored);
402
+ if (verified === void 0) {
403
+ if (stored !== void 0) {
404
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
405
+ }
406
+ return void 0;
407
+ }
408
+ const proofBinding = snapshotDpopBinding(verified);
409
+ const alg = verified.alg;
410
+ if (proofBinding === void 0 || !isDpopAlgorithm(alg) || !policy?.algorithms.includes(alg)) {
411
+ throw invalidBinding("Verified DPoP context must use a currently allowed asymmetric proof algorithm.");
412
+ }
413
+ if (stored === void 0) return void 0;
414
+ if (stored.jkt !== proofBinding.jkt) throw invalidBinding("Verified DPoP key does not match the original binding.");
415
+ return Object.freeze({ type: "dpop", jkt: stored.jkt, alg });
416
+ };
417
+ var withSessionDeviceBinding = (session, binding) => {
418
+ const restored = {};
419
+ for (const key of Reflect.ownKeys(session)) {
420
+ if (key !== "deviceBinding" && Object.prototype.propertyIsEnumerable.call(session, key))
421
+ Object.defineProperty(restored, key, {
422
+ value: Reflect.get(session, key),
423
+ enumerable: true,
424
+ configurable: true,
425
+ writable: true
426
+ });
427
+ }
428
+ const stored = snapshotDpopBinding(binding);
429
+ if (stored !== void 0) restored.deviceBinding = stored;
430
+ return restored;
431
+ };
432
+
433
+ // src/dpop-proof.ts
434
+ var import_node_crypto2 = require("crypto");
435
+ var import_jose = require("jose");
436
+
437
+ // src/dpop-nonce.ts
438
+ var import_node_crypto = require("crypto");
439
+ var NONCE_VERSION = "v1";
440
+ var HMAC_DOMAIN = "@web-ts-toolkit/express-oidc-vault/dpop-nonce/v1\0";
441
+ var DPOP_NONCE_MAX_BYTES = 512;
442
+ var isEpochMs = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
443
+ var decodeBase64url = (value, byteLength) => {
444
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
445
+ const bytes = Buffer.from(value, "base64url");
446
+ if (bytes.toString("base64url") !== value || byteLength !== void 0 && bytes.length !== byteLength) return null;
447
+ return bytes;
448
+ };
449
+ var createDpopNoncePolicy = (options) => {
450
+ const { secret: configuredSecret, lifetimeSeconds: configuredLifetime, namespace } = options;
451
+ if (!(configuredSecret instanceof Uint8Array) || configuredSecret.byteLength < 32) {
452
+ throw new TypeError("DPoP nonce secret must be a Uint8Array of at least 32 bytes.");
453
+ }
454
+ const lifetimeSeconds = configuredLifetime === void 0 ? 60 : configuredLifetime;
455
+ if (!Number.isInteger(lifetimeSeconds) || lifetimeSeconds < 1 || lifetimeSeconds > 300) {
456
+ throw new TypeError("DPoP nonce lifetime must be an integer from 1 through 300 seconds.");
457
+ }
458
+ if (typeof namespace !== "string" || namespace.length === 0) {
459
+ throw new TypeError("DPoP nonce requires a resolved protection-space namespace.");
460
+ }
461
+ const secret = Uint8Array.from(configuredSecret);
462
+ const namespaceDigest = (0, import_node_crypto.createHash)("sha256").update(namespace, "utf8").digest("base64url");
463
+ const lifetimeMs = lifetimeSeconds * 1e3;
464
+ const sign = (encodedPayload) => (0, import_node_crypto.createHmac)("sha256", secret).update(HMAC_DOMAIN, "utf8").update(`${NONCE_VERSION}.${encodedPayload}`, "ascii").digest();
465
+ return Object.freeze({
466
+ issue(jkt, now) {
467
+ const expiresAt = now + lifetimeMs;
468
+ if (!isCanonicalDpopJkt(jkt) || !isEpochMs(now) || !isEpochMs(expiresAt) || expiresAt <= now) {
469
+ throw new TypeError("DPoP nonce issuance requires a canonical key thumbprint and a safe live epoch window.");
470
+ }
471
+ const payload = JSON.stringify([(0, import_node_crypto.randomBytes)(16).toString("base64url"), now, expiresAt, namespaceDigest, jkt]);
472
+ const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
473
+ const nonce = `${NONCE_VERSION}.${encodedPayload}.${sign(encodedPayload).toString("base64url")}`;
474
+ if (Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES) {
475
+ throw new TypeError("DPoP nonce issuance exceeded its fixed wire bound.");
476
+ }
477
+ return nonce;
478
+ },
479
+ verify(nonce, jkt, now) {
480
+ if (!isCanonicalDpopJkt(jkt) || !isEpochMs(now) || typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)
481
+ return false;
482
+ const [version, encodedPayload, encodedMac, extra] = nonce.split(".");
483
+ if (version !== NONCE_VERSION || !encodedPayload || !encodedMac || extra !== void 0) return false;
484
+ const mac = decodeBase64url(encodedMac, 32);
485
+ const bytes = decodeBase64url(encodedPayload);
486
+ if (!mac || !bytes || !(0, import_node_crypto.timingSafeEqual)(mac, sign(encodedPayload))) return false;
487
+ let payload;
488
+ try {
489
+ payload = JSON.parse(bytes.toString("utf8"));
490
+ } catch {
491
+ return false;
492
+ }
493
+ if (!Array.isArray(payload) || payload.length !== 5 || !bytes.equals(Buffer.from(JSON.stringify(payload), "utf8")))
494
+ return false;
495
+ const [random, issuedAt, expiresAt, context, key] = payload;
496
+ if (typeof random !== "string" || !decodeBase64url(random, 16) || !isEpochMs(issuedAt) || !isEpochMs(expiresAt) || context !== namespaceDigest || key !== jkt)
497
+ return false;
498
+ const declaredLifetime = expiresAt - issuedAt;
499
+ return declaredLifetime >= 1e3 && declaredLifetime <= lifetimeMs && declaredLifetime % 1e3 === 0 && issuedAt <= now && now < expiresAt;
500
+ }
501
+ });
502
+ };
503
+
504
+ // src/dpop-target.ts
505
+ var invalidTarget = () => new OidcVaultHttpError(
506
+ 401,
507
+ "OIDC_VAULT_INVALID_DPOP_PROOF",
508
+ "DPoP target must be a valid pinned HTTP(S) URL/path.",
509
+ "DPoP proof validation failed."
510
+ );
511
+ var hasControl = (value) => {
512
+ for (let index = 0; index < value.length; index++) {
513
+ const code = value.charCodeAt(index);
514
+ if (code < 32 || code === 127) return true;
515
+ }
516
+ return false;
517
+ };
518
+ var normalizePath = (origin, path) => {
519
+ if (!path.startsWith("/") || /[\s\\]/.test(path) || hasControl(path) || /%(?![0-9a-f]{2})/i.test(path))
520
+ throw invalidTarget();
521
+ const decoded = path.replace(/%([0-9a-f]{2})/gi, (_, hex) => {
522
+ const character = String.fromCharCode(Number.parseInt(hex, 16));
523
+ return /^[A-Za-z0-9._~-]$/.test(character) ? character : `%${hex.toUpperCase()}`;
524
+ });
525
+ return new URL(`${origin}${decoded}`).pathname;
526
+ };
527
+ var createDpopRequestTargetResolver = (options) => {
528
+ const { publicOrigin, publicPathPrefix } = options;
529
+ const origin = resolveDeviceBindingOrigin(publicOrigin, "publicOrigin");
530
+ let prefix = "";
531
+ if (publicPathPrefix !== void 0 && publicPathPrefix !== "") {
532
+ if (typeof publicPathPrefix !== "string" || /[?#]/.test(publicPathPrefix)) {
533
+ throw new TypeError("deviceBinding.publicPathPrefix must be an absolute path prefix without query or fragment.");
534
+ }
535
+ try {
536
+ prefix = normalizePath(origin, publicPathPrefix).replace(/\/+$/g, "");
537
+ } catch {
538
+ throw new TypeError("deviceBinding.publicPathPrefix must be a valid absolute path prefix.");
539
+ }
540
+ }
541
+ return (originalUrl) => {
542
+ if (typeof originalUrl !== "string") throw invalidTarget();
543
+ const path = originalUrl.split(/[?#]/, 1)[0];
544
+ if (!path.startsWith("/")) throw invalidTarget();
545
+ return `${origin}${normalizePath(origin, `${prefix}${path}`)}`;
546
+ };
547
+ };
548
+ var normalizeDpopProofTarget = (value) => {
549
+ if (typeof value !== "string" || /[\s\\]/.test(value) || hasControl(value) || !/^https?:\/\/[^/\\?#]+/i.test(value))
550
+ throw invalidTarget();
551
+ let url;
552
+ try {
553
+ url = new URL(value);
554
+ } catch {
555
+ throw invalidTarget();
556
+ }
557
+ const queryFree = value.split(/[?#]/, 1)[0];
558
+ const authority = queryFree.slice(queryFree.indexOf("://") + 3).split("/", 1)[0];
559
+ if (url.username || url.password || url.origin === "null" || authority.includes("@")) throw invalidTarget();
560
+ const authorityEnd = queryFree.indexOf("/", queryFree.indexOf("://") + 3);
561
+ const path = authorityEnd === -1 ? "/" : queryFree.slice(authorityEnd);
562
+ return `${url.origin}${normalizePath(url.origin, path)}`;
563
+ };
564
+
565
+ // src/dpop-proof.ts
566
+ var DPOP_PROOF_MAX_BYTES = 8192;
567
+ var DPOP_PROTECTED_HEADER_MAX_BYTES = 2048;
568
+ var readDpopRawHeader = (req, name) => {
569
+ const normalizedName = name.toLowerCase();
570
+ let count = 0;
571
+ let value;
572
+ for (let index = 0; index < req.rawHeaders.length; index += 2) {
573
+ if (req.rawHeaders[index].toLowerCase() !== normalizedName) continue;
574
+ if (++count === 1) value = req.rawHeaders[index + 1];
575
+ if (count === 2) break;
576
+ }
577
+ return Object.freeze({ count, value });
578
+ };
579
+ var invalidProof = (diagnostic, cause) => {
580
+ const error = new OidcVaultHttpError(
581
+ 401,
582
+ "OIDC_VAULT_INVALID_DPOP_PROOF",
583
+ diagnostic,
584
+ "DPoP proof validation failed."
585
+ );
586
+ if (cause !== void 0) Object.defineProperty(error, "cause", { value: cause, configurable: true });
587
+ return error;
588
+ };
589
+ var extractDpopProof = (header) => {
590
+ if (header.count === 0) return void 0;
591
+ const { value } = header;
592
+ if (header.count !== 1 || typeof value !== "string" || value.length === 0 || value.length > DPOP_PROOF_MAX_BYTES || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) {
593
+ throw invalidProof("DPoP header must contain one bounded compact signed proof.");
594
+ }
595
+ return value;
596
+ };
597
+ var decodeBase64url2 = (value, maxBytes) => {
598
+ if (!value || value.length > Math.ceil(maxBytes * 4 / 3) || !/^[A-Za-z0-9_-]+$/.test(value)) {
599
+ throw invalidProof("DPoP base64url input exceeds its bound or has invalid encoding.");
600
+ }
601
+ const bytes = Buffer.from(value, "base64url");
602
+ if (bytes.length > maxBytes || bytes.toString("base64url") !== value)
603
+ throw invalidProof("DPoP base64url input is not canonical.");
604
+ return bytes;
605
+ };
606
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
607
+ var parseObject = (bytes) => {
608
+ try {
609
+ const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
610
+ const value = JSON.parse(json);
611
+ if (!isPlainObject(value)) throw new TypeError("DPoP JSON must be an object.");
612
+ const stack = [];
613
+ for (let index = 0; index < json.length; index++) {
614
+ const character = json[index];
615
+ if (character === "{" || character === "[") {
616
+ stack.push({ keys: character === "{" ? /* @__PURE__ */ new Set() : null, expectsKey: character === "{" });
617
+ } else if (character === "}" || character === "]") {
618
+ stack.pop();
619
+ } else if (character === ",") {
620
+ const container = stack[stack.length - 1];
621
+ if (container?.keys) container.expectsKey = true;
622
+ } else if (character === '"') {
623
+ const start = index++;
624
+ for (; index < json.length; index++) {
625
+ if (json[index] === "\\") index++;
626
+ else if (json[index] === '"') break;
627
+ }
628
+ const container = stack[stack.length - 1];
629
+ if (container?.keys && container.expectsKey) {
630
+ const key = JSON.parse(json.slice(start, index + 1));
631
+ if (container.keys.has(key)) throw new TypeError("DPoP JSON has duplicate member names.");
632
+ container.keys.add(key);
633
+ container.expectsKey = false;
634
+ }
635
+ }
636
+ }
637
+ return value;
638
+ } catch (cause) {
639
+ throw invalidProof("DPoP JSON encoding or object shape is invalid.", cause);
640
+ }
641
+ };
642
+ var publicJwk = (value, alg) => {
643
+ if (!isPlainObject(value) || Buffer.byteLength(JSON.stringify(value), "utf8") > DPOP_PROTECTED_HEADER_MAX_BYTES) {
644
+ throw invalidProof("DPoP JWK must be a bounded public object.");
645
+ }
646
+ const allowed = alg === "ES256" ? ["kty", "crv", "x", "y", "alg", "use", "key_ops", "kid"] : ["kty", "n", "e", "alg", "use", "key_ops", "kid"];
647
+ if (Object.keys(value).some((key) => !allowed.includes(key)) || value.alg !== void 0 && value.alg !== alg || value.use !== void 0 && value.use !== "sig" || value.kid !== void 0 && typeof value.kid !== "string" || value.key_ops !== void 0 && (!Array.isArray(value.key_ops) || value.key_ops.length !== 1 || value.key_ops[0] !== "verify")) {
648
+ throw invalidProof("DPoP JWK contains private, remote, unsupported or incompatible key parameters.");
649
+ }
650
+ if (alg === "ES256") {
651
+ if (value.kty !== "EC" || value.crv !== "P-256" || typeof value.x !== "string" || typeof value.y !== "string" || decodeBase64url2(value.x, 32).length !== 32 || decodeBase64url2(value.y, 32).length !== 32) {
652
+ throw invalidProof("DPoP ES256 requires a public P-256 key with canonical coordinates.");
653
+ }
654
+ } else {
655
+ if (value.kty !== "RSA" || typeof value.n !== "string" || typeof value.e !== "string") {
656
+ throw invalidProof("DPoP RSA requires public modulus and exponent.");
657
+ }
658
+ const modulus = decodeBase64url2(value.n, 512);
659
+ const exponent = decodeBase64url2(value.e, 512);
660
+ const bits = (modulus.length - 1) * 8 + 32 - Math.clz32(modulus[0]);
661
+ if (modulus[0] === 0 || bits < 2048 || bits > 4096 || exponent[0] === 0 || (exponent[exponent.length - 1] & 1) === 0 || exponent.length === 1 && exponent[0] < 3 || exponent.length > modulus.length || exponent.length === modulus.length && Buffer.compare(exponent, modulus) >= 0) {
662
+ throw invalidProof(
663
+ "DPoP RSA public parameters must use a 2048\u20134096-bit modulus and a valid bounded odd exponent."
664
+ );
665
+ }
666
+ }
667
+ return value;
668
+ };
669
+ var verifyDpopProof = async (input) => {
670
+ const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
671
+ const algorithms = [...proofOptions.algorithms];
672
+ extractDpopProof({ count: 1, value: proof });
673
+ const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
674
+ const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
675
+ decodeBase64url2(encodedPayload, DPOP_PROOF_MAX_BYTES);
676
+ const signature = decodeBase64url2(encodedSignature, 512);
677
+ const { alg } = header;
678
+ if (header.typ !== "dpop+jwt" || !isDpopAlgorithm(alg) || !algorithms.includes(alg) || ["crit", "b64", "jku", "x5u", "x5c", "x5t", "x5t#S256"].some((key) => Object.hasOwn(header, key))) {
679
+ throw invalidProof("DPoP protected header has an unsupported type, algorithm or key/critical extension.");
680
+ }
681
+ const jwk = publicJwk(header.jwk, alg);
682
+ const signatureBytes = alg === "ES256" ? 64 : decodeBase64url2(jwk.n, 512).length;
683
+ if (signature.length !== signatureBytes)
684
+ throw invalidProof("DPoP signature length does not match the allowed key profile.");
685
+ let payload;
686
+ try {
687
+ const key = await (0, import_jose.importJWK)(jwk, alg);
688
+ ({ payload } = await (0, import_jose.compactVerify)(proof, key, { algorithms }));
689
+ } catch (cause) {
690
+ throw invalidProof("DPoP public-key import or signature verification failed.", cause);
691
+ }
692
+ const claims = parseObject(payload);
693
+ const { htm, htu, iat, jti, nonce, ath } = claims;
694
+ if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
695
+ throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
696
+ }
697
+ if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== targetUrl) {
698
+ throw invalidProof("DPoP signed method or normalized public target does not match this request.");
699
+ }
700
+ if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
701
+ throw invalidProof("DPoP signed nonce exceeds its byte bound.");
702
+ }
703
+ if (accessToken !== void 0 && (typeof accessToken !== "string" || !/^[\x21-\x7e]+$/.test(accessToken) || !isCanonicalDpopJkt(ath) || ath !== (0, import_node_crypto2.createHash)("sha256").update(accessToken, "ascii").digest("base64url"))) {
704
+ throw invalidProof("DPoP signed access-token hash does not match the presented API token.");
705
+ }
706
+ const jkt = await (0, import_jose.calculateJwkThumbprint)(jwk, "sha256");
707
+ if (expectedJkt !== void 0 && (!isCanonicalDpopJkt(expectedJkt) || jkt !== expectedJkt)) {
708
+ throw invalidProof("DPoP verified public key does not match the original credential confirmation.");
709
+ }
710
+ return Object.freeze({
711
+ binding: Object.freeze({ type: "dpop", jkt, alg }),
712
+ iat,
713
+ jti,
714
+ ...nonce === void 0 ? {} : { nonce }
715
+ });
716
+ };
717
+
718
+ // src/fingerprint-recognition.ts
719
+ var FINGERPRINT_RECOGNITION_METADATA_KEY = "oidcVaultFingerprintRecognition";
720
+ var isPlainRecord2 = (value) => typeof value === "object" && value !== null && [Object.prototype, null].includes(Object.getPrototypeOf(value));
721
+ var COLLIDING_HEADERS = /* @__PURE__ */ new Set([
722
+ "authorization",
723
+ "proxy-authorization",
724
+ "www-authenticate",
725
+ "proxy-authenticate",
726
+ "authentication-info",
727
+ "proxy-authentication-info",
728
+ "dpop",
729
+ "dpop-nonce",
730
+ "cookie",
731
+ "set-cookie",
732
+ "origin",
733
+ "referer",
734
+ "host",
735
+ "forwarded",
736
+ "accept",
737
+ "accept-charset",
738
+ "accept-encoding",
739
+ "accept-language",
740
+ "connection",
741
+ "keep-alive",
742
+ "te",
743
+ "trailer",
744
+ "transfer-encoding",
745
+ "upgrade"
746
+ ]);
747
+ var resolveFingerprintRecognitionOptions = (value) => {
748
+ if (value === void 0) return void 0;
749
+ if (!isPlainRecord2(value)) throw new Error("fingerprintRecognition must be a plain options object.");
750
+ const { headerName } = value;
751
+ const name = headerName === void 0 ? "X-Device-Fingerprint" : headerName;
752
+ if (!isValidCookieName(name)) throw new Error("fingerprintRecognition.headerName must be a valid HTTP field name.");
753
+ const normalized = name.toLowerCase();
754
+ if (COLLIDING_HEADERS.has(normalized) || ["content-", "x-forwarded-", "access-control-", "sec-fetch-"].some((prefix) => normalized.startsWith(prefix))) {
755
+ throw new Error(
756
+ "fingerprintRecognition.headerName must not collide with authentication, cookie, origin, content, or transport headers."
757
+ );
758
+ }
759
+ return Object.freeze({ headerName: name });
760
+ };
761
+ var invalidFingerprint = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_FINGERPRINT", "Fingerprint signal is invalid.");
762
+ var captureFingerprintRecognition = (req, policy) => {
763
+ if (policy === void 0) return void 0;
764
+ const { count, value } = readDpopRawHeader(req, policy.headerName);
765
+ if (count === 0) return void 0;
766
+ if (count !== 1 || typeof value !== "string" || value.length === 0 || value.length > 256) throw invalidFingerprint();
767
+ for (let index = 0; index < value.length; index++) {
768
+ const byte = value.charCodeAt(index);
769
+ if (byte < 32 || byte > 126) throw invalidFingerprint();
770
+ }
771
+ return Object.freeze({ version: 1, hash: (0, import_node_crypto3.createHash)("sha256").update(value, "ascii").digest("base64url") });
772
+ };
773
+ var snapshotFingerprintRecognition = (metadata) => {
774
+ if (metadata === void 0 || !Object.hasOwn(metadata, FINGERPRINT_RECOGNITION_METADATA_KEY)) return void 0;
775
+ const value = metadata?.[FINGERPRINT_RECOGNITION_METADATA_KEY];
776
+ if (value === void 0) return void 0;
777
+ if (!isPlainRecord2(value)) throw new TypeError("Stored fingerprint recognition metadata is invalid.");
778
+ const { version, hash } = value;
779
+ if (Object.keys(value).length !== 2 || !Object.hasOwn(value, "version") || !Object.hasOwn(value, "hash") || version !== 1 || !isCanonicalDpopJkt(hash)) {
780
+ throw new TypeError("Stored fingerprint recognition metadata is invalid.");
781
+ }
782
+ return Object.freeze({ version, hash });
783
+ };
784
+ var fingerprintRecognitionMatches = (actual, original) => actual?.hash === original?.hash;
785
+ var assertFingerprintRecognition = (policy, original, supplied) => {
786
+ if (policy !== void 0 && original !== void 0 && !fingerprintRecognitionMatches(supplied, original)) {
787
+ throw new OidcVaultHttpError(
788
+ 403,
789
+ "OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED",
790
+ "Browser recognition changed; sign in again."
791
+ );
792
+ }
793
+ };
794
+ var withFingerprintRecognitionMetadata = (metadata, original) => {
795
+ if (metadata === void 0 && original === void 0) return void 0;
796
+ if (original === void 0 && metadata !== void 0 && !Object.hasOwn(metadata, FINGERPRINT_RECOGNITION_METADATA_KEY))
797
+ return metadata;
798
+ const restored = {};
799
+ for (const name of Object.keys(metadata ?? {})) {
800
+ if (name !== FINGERPRINT_RECOGNITION_METADATA_KEY)
801
+ Object.defineProperty(restored, name, {
802
+ value: Reflect.get(metadata, name),
803
+ enumerable: true,
804
+ configurable: true,
805
+ writable: true
806
+ });
807
+ }
808
+ if (original !== void 0) restored[FINGERPRINT_RECOGNITION_METADATA_KEY] = { version: 1, hash: original.hash };
809
+ return restored;
810
+ };
811
+
402
812
  // src/lifetime-policy.ts
403
813
  var MAX_EPOCH_MS = 864e13;
404
814
  var isUsableEpochMs = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= MAX_EPOCH_MS;
@@ -428,6 +838,283 @@ var validateLifetimeOptions = (options, now) => {
428
838
  }
429
839
  };
430
840
 
841
+ // src/transaction-cookie.ts
842
+ var import_node_crypto4 = require("crypto");
843
+ var invalidBrowserBinding = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_BROWSER_BINDING", "Login browser binding validation failed.");
844
+ var resolveTransactionCookieOptions = (options, backendOrigin) => {
845
+ const value = options.transactionCookie;
846
+ if (value !== void 0 && (typeof value !== "object" || value === null || ![Object.prototype, null].includes(Object.getPrototypeOf(value)))) {
847
+ throw new Error("transactionCookie must be a plain options object.");
848
+ }
849
+ if (value !== void 0 && Object.keys(value).some((key) => key !== "name" && key !== "sameSite")) {
850
+ throw new Error(
851
+ "transactionCookie supports only name and sameSite; browser-binding cookie security cannot be overridden."
852
+ );
853
+ }
854
+ const { name, sameSite } = value ?? {};
855
+ const secure = new URL(backendOrigin).protocol === "https:";
856
+ const resolvedName = name === void 0 ? secure ? "__Host-oidc_vault_transaction" : "oidc_vault_transaction" : name;
857
+ if (!isValidCookieName(resolvedName)) throw new Error("transactionCookie.name must be a valid HTTP cookie name.");
858
+ if (sameSite !== void 0 && sameSite !== "lax" && sameSite !== "none") {
859
+ throw new Error("transactionCookie.sameSite must be lax or none.");
860
+ }
861
+ if (!secure && sameSite === "none") throw new Error("transactionCookie.sameSite none requires HTTPS.");
862
+ if (!secure && (resolvedName.startsWith("__Host-") || resolvedName.startsWith("__Secure-"))) {
863
+ throw new Error("transactionCookie.name with the __Host- or __Secure- prefix requires HTTPS.");
864
+ }
865
+ if ((options.deviceBinding !== void 0 || options.fingerprintRecognition !== void 0 || value !== void 0) && resolvedName === resolveCookieOptions(options).name) {
866
+ throw new Error("transactionCookie.name must be distinct from cookie.name.");
867
+ }
868
+ return Object.freeze({ name: resolvedName, sameSite: sameSite ?? "lax", secure, path: "/", httpOnly: true });
869
+ };
870
+ var hashSecret = (secret) => (0, import_node_crypto4.createHash)("sha256").update(secret, "ascii").digest("base64url");
871
+ var createTransactionBrowserBinding = () => {
872
+ const secret = (0, import_node_crypto4.randomBytes)(32).toString("base64url");
873
+ return Object.freeze({ secret, browserBindingHash: hashSecret(secret) });
874
+ };
875
+ var parseTransactionCookie = (header, selectedName) => {
876
+ if (header === void 0) return void 0;
877
+ if (typeof header !== "string") throw invalidBrowserBinding();
878
+ let selected;
879
+ for (let start = 0; start < header.length; ) {
880
+ const delimiter = header.indexOf(";", start);
881
+ const end = delimiter === -1 ? header.length : delimiter;
882
+ let separator = start;
883
+ while (separator < end && header[separator] !== "=") separator += 1;
884
+ const hasValue = separator < end;
885
+ let nameStart = start;
886
+ let nameEnd = hasValue ? separator : end;
887
+ while (nameStart < nameEnd && (header[nameStart] === " " || header[nameStart] === " ")) nameStart += 1;
888
+ while (nameEnd > nameStart && (header[nameEnd - 1] === " " || header[nameEnd - 1] === " ")) nameEnd -= 1;
889
+ if (nameEnd - nameStart === selectedName.length && header.startsWith(selectedName, nameStart)) {
890
+ if (selected !== void 0 || !hasValue) throw invalidBrowserBinding();
891
+ if (end - separator - 1 !== 43) throw invalidBrowserBinding();
892
+ const value = header.slice(separator + 1, end);
893
+ if (!isCanonicalDpopJkt(value)) throw invalidBrowserBinding();
894
+ selected = value;
895
+ }
896
+ start = end + 1;
897
+ }
898
+ return selected;
899
+ };
900
+ var snapshotRecordBindingMatch = (record) => {
901
+ const { deviceBinding, browserBindingHash } = record;
902
+ const binding = snapshotDpopBinding(deviceBinding);
903
+ if (binding !== void 0 && Object.keys(deviceBinding).length !== 2) {
904
+ throw new OidcVaultHttpError(
905
+ 401,
906
+ "OIDC_VAULT_INVALID_DPOP_PROOF",
907
+ "Stored device binding contains unsupported fields.",
908
+ "DPoP proof validation failed."
909
+ );
910
+ }
911
+ if (browserBindingHash !== void 0 && !isCanonicalDpopJkt(browserBindingHash) || binding !== void 0 && browserBindingHash === void 0)
912
+ throw invalidBrowserBinding();
913
+ return Object.freeze({ deviceBinding: binding ?? null, browserBindingHash: browserBindingHash ?? null });
914
+ };
915
+ var recordBindingMatches = (actual, expected) => actual.deviceBinding?.jkt === expected.deviceBinding?.jkt && actual.browserBindingHash === expected.browserBindingHash;
916
+ var authenticateTransactionCookie = (header, cookie, match) => {
917
+ if (match.browserBindingHash === null) return void 0;
918
+ const secret = parseTransactionCookie(header, cookie.name);
919
+ if (secret === void 0 || hashSecret(secret) !== match.browserBindingHash) throw invalidBrowserBinding();
920
+ return secret;
921
+ };
922
+ var setTransactionCookie = (res, cookie, secret, expiresAt, now) => {
923
+ if (!isCanonicalDpopJkt(secret) || !isUsableEpochMs(expiresAt) || !isUsableEpochMs(now)) {
924
+ throw new TypeError("OIDC vault transaction cookie requires a canonical secret and usable deadline/clock.");
925
+ }
926
+ res.append(
927
+ "Set-Cookie",
928
+ serializeCookie(cookie.name, secret, cookie, {
929
+ maxAge: Math.max(0, Math.floor((expiresAt - now) / 1e3)),
930
+ expires: new Date(Math.floor(expiresAt / 1e3) * 1e3)
931
+ })
932
+ );
933
+ };
934
+ var clearTransactionCookie = (res, cookie) => {
935
+ res.append("Set-Cookie", serializeCookie(cookie.name, "", cookie, { maxAge: 0, expires: /* @__PURE__ */ new Date(0) }));
936
+ };
937
+
938
+ // src/authorization-transaction.ts
939
+ var PROVIDER_METADATA_KEY = "oidcVaultTransactionProvider";
940
+ var invalidAuthorizationState = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_STATE", "OIDC state is invalid or expired.");
941
+ var createTransactionProviderMetadata = (config) => ({
942
+ [PROVIDER_METADATA_KEY]: { issuer: config.issuer, clientId: config.clientId }
943
+ });
944
+ var snapshotAuthorizationTransaction = (record, state, now) => {
945
+ const { state: storedState, nonce, pkceVerifier, codeChallenge, returnTo, createdAt, expiresAt, metadata } = record;
946
+ if (storedState !== state || typeof nonce !== "string" || typeof pkceVerifier !== "string" || typeof codeChallenge !== "string" || returnTo !== void 0 && typeof returnTo !== "string" || !isUsableEpochMs(createdAt) || !isUsableEpochMs(expiresAt) || !isUsableEpochMs(now) || expiresAt <= now) {
947
+ throw invalidAuthorizationState();
948
+ }
949
+ const match = snapshotRecordBindingMatch(record);
950
+ let fingerprintRecognition;
951
+ try {
952
+ fingerprintRecognition = snapshotFingerprintRecognition(metadata);
953
+ } catch {
954
+ throw invalidAuthorizationState();
955
+ }
956
+ if (fingerprintRecognition !== void 0 && match.browserBindingHash === null) throw invalidAuthorizationState();
957
+ const value = metadata?.[PROVIDER_METADATA_KEY];
958
+ let provider;
959
+ if (value !== void 0) {
960
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidAuthorizationState();
961
+ const { issuer, clientId } = value;
962
+ if (issuer !== void 0 && typeof issuer !== "string" || clientId !== void 0 && typeof clientId !== "string") {
963
+ throw invalidAuthorizationState();
964
+ }
965
+ provider = Object.freeze({
966
+ ...issuer === void 0 ? {} : { issuer },
967
+ ...clientId === void 0 ? {} : { clientId }
968
+ });
969
+ }
970
+ return Object.freeze({
971
+ state,
972
+ nonce,
973
+ pkceVerifier,
974
+ codeChallenge,
975
+ returnTo,
976
+ createdAt,
977
+ expiresAt,
978
+ ...metadata === void 0 ? {} : {
979
+ metadata: Object.freeze({
980
+ ...withFingerprintRecognitionMetadata(metadata, fingerprintRecognition),
981
+ ...provider === void 0 ? {} : { [PROVIDER_METADATA_KEY]: provider },
982
+ ...fingerprintRecognition === void 0 ? {} : { [FINGERPRINT_RECOGNITION_METADATA_KEY]: fingerprintRecognition }
983
+ })
984
+ },
985
+ ...match.deviceBinding === null ? {} : { deviceBinding: match.deviceBinding },
986
+ ...match.browserBindingHash === null ? {} : { browserBindingHash: match.browserBindingHash },
987
+ match,
988
+ ...provider === void 0 ? {} : { provider },
989
+ ...fingerprintRecognition === void 0 ? {} : { fingerprintRecognition }
990
+ });
991
+ };
992
+ var assertAuthorizationTransactionIdentity = (transaction, config) => {
993
+ if (transaction.provider?.issuer !== void 0 && transaction.provider.issuer !== config.issuer || transaction.provider?.clientId !== void 0 && transaction.provider.clientId !== config.clientId) {
994
+ throw invalidAuthorizationState();
995
+ }
996
+ };
997
+ var assertAuthorizationTransactionMatches = (actual, original) => {
998
+ if (actual.state !== original.state || actual.nonce !== original.nonce || actual.pkceVerifier !== original.pkceVerifier || actual.codeChallenge !== original.codeChallenge || actual.returnTo !== original.returnTo || actual.createdAt !== original.createdAt || actual.expiresAt !== original.expiresAt || actual.provider?.issuer !== original.provider?.issuer || actual.provider?.clientId !== original.provider?.clientId || !fingerprintRecognitionMatches(actual.fingerprintRecognition, original.fingerprintRecognition) || !recordBindingMatches(actual.match, original.match))
999
+ throw invalidAuthorizationState();
1000
+ };
1001
+
1002
+ // src/config.ts
1003
+ var DEFAULT_OIDC_SCOPES = "openid email profile";
1004
+ var MANUAL_CONFIG_REQUIREMENTS = ["issuer", "authorizationEndpoint", "tokenEndpoint", "jwksUri"];
1005
+ var MANUAL_CONFIG_SELECTORS = ["authorizationEndpoint", "tokenEndpoint", "jwksUri", "userInfoEndpoint", "endSessionEndpoint"];
1006
+ var normalizeOptionalString = (value) => {
1007
+ const trimmed = value?.trim();
1008
+ return trimmed ? trimmed : void 0;
1009
+ };
1010
+ var normalizeHttpUrl = (value, optionName) => {
1011
+ if (value === void 0) {
1012
+ return void 0;
1013
+ }
1014
+ let url;
1015
+ try {
1016
+ url = new URL(value);
1017
+ } catch {
1018
+ throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
1019
+ }
1020
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
1021
+ throw new Error(`${optionName} must use http or https.`);
1022
+ }
1023
+ return url.toString();
1024
+ };
1025
+ var normalizeIssuerIdentifier = (value, optionName) => {
1026
+ if (value === void 0) {
1027
+ return void 0;
1028
+ }
1029
+ let url;
1030
+ try {
1031
+ url = new URL(value);
1032
+ } catch {
1033
+ throw new Error(`${optionName} must be an absolute HTTP(S) URL.`);
1034
+ }
1035
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.origin === "null") {
1036
+ throw new Error(`${optionName} must use http or https.`);
1037
+ }
1038
+ if (url.username !== "" || url.password !== "") {
1039
+ throw new Error(`${optionName} must not include userinfo.`);
1040
+ }
1041
+ if (url.search !== "" || url.hash !== "") {
1042
+ throw new Error(`${optionName} must not include query or fragment.`);
1043
+ }
1044
+ return value;
1045
+ };
1046
+ var withOptionalValue = (target, key, value) => {
1047
+ if (value !== void 0) {
1048
+ target[key] = value;
1049
+ }
1050
+ };
1051
+ function resolveOidcVaultConfig(config = {}) {
1052
+ const issuer = normalizeIssuerIdentifier(normalizeOptionalString(config.issuer), "config.issuer");
1053
+ const clientId = normalizeOptionalString(config.clientId);
1054
+ const clientSecret = normalizeOptionalString(config.clientSecret);
1055
+ const scopes = normalizeOptionalString(config.scopes) ?? DEFAULT_OIDC_SCOPES;
1056
+ const hasManualEndpoints = MANUAL_CONFIG_SELECTORS.some((key) => normalizeOptionalString(config[key]));
1057
+ if (!clientId) {
1058
+ throw new Error("OIDC clientId is required.");
1059
+ }
1060
+ if (issuer && !hasManualEndpoints) {
1061
+ const resolved2 = {
1062
+ mode: "issuer",
1063
+ issuer,
1064
+ scopes
1065
+ };
1066
+ withOptionalValue(resolved2, "clientId", clientId);
1067
+ withOptionalValue(resolved2, "clientSecret", clientSecret);
1068
+ return resolved2;
1069
+ }
1070
+ const resolved = {
1071
+ mode: "manual",
1072
+ scopes
1073
+ };
1074
+ withOptionalValue(resolved, "issuer", issuer);
1075
+ withOptionalValue(
1076
+ resolved,
1077
+ "authorizationEndpoint",
1078
+ normalizeHttpUrl(normalizeOptionalString(config.authorizationEndpoint), "config.authorizationEndpoint")
1079
+ );
1080
+ withOptionalValue(
1081
+ resolved,
1082
+ "tokenEndpoint",
1083
+ normalizeHttpUrl(normalizeOptionalString(config.tokenEndpoint), "config.tokenEndpoint")
1084
+ );
1085
+ withOptionalValue(
1086
+ resolved,
1087
+ "userInfoEndpoint",
1088
+ normalizeHttpUrl(normalizeOptionalString(config.userInfoEndpoint), "config.userInfoEndpoint")
1089
+ );
1090
+ withOptionalValue(resolved, "jwksUri", normalizeHttpUrl(normalizeOptionalString(config.jwksUri), "config.jwksUri"));
1091
+ withOptionalValue(
1092
+ resolved,
1093
+ "endSessionEndpoint",
1094
+ normalizeHttpUrl(normalizeOptionalString(config.endSessionEndpoint), "config.endSessionEndpoint")
1095
+ );
1096
+ withOptionalValue(resolved, "clientId", clientId);
1097
+ withOptionalValue(resolved, "clientSecret", clientSecret);
1098
+ const missing = MANUAL_CONFIG_REQUIREMENTS.filter((key) => !resolved[key]);
1099
+ if (missing.length > 0) {
1100
+ throw new Error(`Missing required OIDC configuration for manual mode: ${missing.join(", ")}`);
1101
+ }
1102
+ return resolved;
1103
+ }
1104
+ function resolveOidcVaultConfigFromEnv(env = process.env) {
1105
+ return resolveOidcVaultConfig({
1106
+ issuer: env.OIDC_ISSUER,
1107
+ authorizationEndpoint: env.OIDC_AUTHORIZATION_ENDPOINT,
1108
+ tokenEndpoint: env.OIDC_TOKEN_ENDPOINT,
1109
+ userInfoEndpoint: env.OIDC_USERINFO_ENDPOINT,
1110
+ jwksUri: env.OIDC_JWKS_URI,
1111
+ endSessionEndpoint: env.OIDC_END_SESSION_ENDPOINT,
1112
+ clientId: env.OIDC_CLIENT_ID,
1113
+ clientSecret: env.OIDC_CLIENT_SECRET,
1114
+ scopes: env.OIDC_SCOPES
1115
+ });
1116
+ }
1117
+
431
1118
  // src/utils.ts
432
1119
  var isRecord = (value) => typeof value === "object" && value !== null;
433
1120
  var isString = (value) => typeof value === "string" && value.length > 0;
@@ -510,21 +1197,41 @@ var getRequestSourceOrigin = (req) => {
510
1197
  }
511
1198
  };
512
1199
  var assertTrustedOrigin = (req, options, trustedOrigins, action) => {
513
- if (!usesCookieTransport(options)) {
514
- return;
1200
+ captureTrustedOriginGuard(req, options, trustedOrigins, action)();
1201
+ };
1202
+ var captureTrustedOriginGuard = (req, options, trustedOrigins, action) => {
1203
+ const guarded = action === "login" || action === "exchange";
1204
+ if (!guarded && !usesCookieTransport(options)) {
1205
+ return () => {
1206
+ };
515
1207
  }
516
- const origin = getRequestSourceOrigin(req);
517
- if (!origin || !trustedOrigins.has(origin)) {
518
- throw new OidcVaultHttpError(
519
- 403,
520
- "OIDC_VAULT_UNTRUSTED_ORIGIN",
521
- `${action === "refresh" ? "Refresh" : "Logout"} request origin is not trusted.`
522
- );
1208
+ const origin = guarded ? getGuardedRequestSourceOrigin(req) : getRequestSourceOrigin(req);
1209
+ return () => {
1210
+ if (!origin || !trustedOrigins.has(origin)) {
1211
+ throw new OidcVaultHttpError(
1212
+ 403,
1213
+ "OIDC_VAULT_UNTRUSTED_ORIGIN",
1214
+ `${action.charAt(0).toUpperCase()}${action.slice(1)} request origin is not trusted.`
1215
+ );
1216
+ }
1217
+ };
1218
+ };
1219
+ var getGuardedRequestSourceOrigin = (req) => {
1220
+ const origin = readDpopRawHeader(req, "origin");
1221
+ if (origin.count !== 0) return origin.count === 1 && isString(origin.value) ? origin.value : void 0;
1222
+ const referer = readDpopRawHeader(req, "referer");
1223
+ if (referer.count !== 1 || !isString(referer.value)) return void 0;
1224
+ if (!/^https?:\/\//i.test(referer.value) || /[\s\\]/.test(referer.value)) return void 0;
1225
+ try {
1226
+ const url = new URL(referer.value);
1227
+ return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password ? url.origin : void 0;
1228
+ } catch {
1229
+ return void 0;
523
1230
  }
524
1231
  };
525
1232
 
526
1233
  // src/provider-client.ts
527
- var import_jose = require("jose");
1234
+ var import_jose2 = require("jose");
528
1235
  var DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 5e3;
529
1236
  var DEFAULT_DISCOVERY_CACHE_TTL_MS = 10 * 6e4;
530
1237
  var DEFAULT_DISCOVERY_CACHE_CAPACITY = 32;
@@ -762,10 +1469,10 @@ var resolveJwks = (jwksUri, options = {}) => {
762
1469
  const cacheKey = `${jwksUri}\0${timeoutMs}`;
763
1470
  let jwks = jwksCache.get(cacheKey);
764
1471
  if (!jwks) {
765
- jwks = (0, import_jose.createRemoteJWKSet)(new URL(jwksUri), {
1472
+ jwks = (0, import_jose2.createRemoteJWKSet)(new URL(jwksUri), {
766
1473
  timeoutDuration: timeoutMs,
767
1474
  cacheMaxAge: DEFAULT_DISCOVERY_CACHE_TTL_MS,
768
- [import_jose.customFetch]: createBoundedJwksFetch(timeoutMs)
1475
+ [import_jose2.customFetch]: createBoundedJwksFetch(timeoutMs)
769
1476
  });
770
1477
  jwksCache.set(cacheKey, jwks);
771
1478
  evictOldestEntry(jwksCache, DEFAULT_JWKS_CACHE_CAPACITY);
@@ -1085,8 +1792,243 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
1085
1792
  }
1086
1793
  }
1087
1794
 
1795
+ // src/token-issuance.ts
1796
+ var import_jose3 = require("jose");
1797
+
1798
+ // src/vault-session.ts
1799
+ var invalidVaultSession = () => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1800
+ var invalidExchangeCode = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
1801
+ var isPlainObject2 = (value) => typeof value === "object" && value !== null && [Object.prototype, null].includes(Object.getPrototypeOf(value));
1802
+ var copyPortableData = (value, omitRecognition = false) => {
1803
+ const copies = /* @__PURE__ */ new Map();
1804
+ const pending = [];
1805
+ const copy = (item) => {
1806
+ if (!Array.isArray(item) && !isPlainObject2(item)) return item;
1807
+ const existing = copies.get(item);
1808
+ if (existing) return existing;
1809
+ const target = Array.isArray(item) ? new Array(item.length) : Object.create(Object.getPrototypeOf(item));
1810
+ copies.set(item, target);
1811
+ pending.push({ source: item, target });
1812
+ return target;
1813
+ };
1814
+ const result = copy(value);
1815
+ for (let index = 0; index < pending.length; index++) {
1816
+ const { source, target } = pending[index];
1817
+ for (const key of Object.keys(source)) {
1818
+ if (omitRecognition && key === FINGERPRINT_RECOGNITION_METADATA_KEY) continue;
1819
+ Object.defineProperty(target, key, {
1820
+ value: copy(Reflect.get(source, key)),
1821
+ enumerable: true,
1822
+ configurable: true,
1823
+ writable: true
1824
+ });
1825
+ }
1826
+ }
1827
+ return result;
1828
+ };
1829
+ var copyVaultSession = (session, omitRecognition = false) => copyPortableData(session, omitRecognition);
1830
+ var copyVaultUserProfile = (user) => copyPortableData(user, true);
1831
+ var snapshotProvider = (value) => {
1832
+ if (value === void 0) return void 0;
1833
+ if (!isPlainObject2(value)) throw invalidVaultSession();
1834
+ const { issuer, clientId } = value;
1835
+ if (issuer !== void 0 && typeof issuer !== "string" || clientId !== void 0 && typeof clientId !== "string") {
1836
+ throw invalidVaultSession();
1837
+ }
1838
+ return Object.freeze({
1839
+ ...issuer === void 0 ? {} : { issuer },
1840
+ ...clientId === void 0 ? {} : { clientId }
1841
+ });
1842
+ };
1843
+ var assertVaultSessionIdentity = (session, config) => {
1844
+ if (session.provider?.issuer !== void 0 && session.provider.issuer !== config.issuer || session.provider?.clientId !== void 0 && session.provider.clientId !== config.clientId)
1845
+ throw invalidVaultSession();
1846
+ };
1847
+ var snapshotVaultSession = (record, expectedSessionId, now) => {
1848
+ const {
1849
+ sessionId,
1850
+ logicalSessionId,
1851
+ subject,
1852
+ providerSessionId,
1853
+ provider: rawProvider,
1854
+ deviceBinding: rawBinding,
1855
+ refreshToken,
1856
+ idToken,
1857
+ accessToken,
1858
+ scope,
1859
+ expiresAt,
1860
+ createdAt,
1861
+ updatedAt,
1862
+ user,
1863
+ metadata
1864
+ } = record;
1865
+ if (sessionId !== expectedSessionId || typeof sessionId !== "string" || !sessionId || typeof subject !== "string" || !subject || logicalSessionId !== void 0 && (typeof logicalSessionId !== "string" || !logicalSessionId) || providerSessionId !== void 0 && typeof providerSessionId !== "string" || typeof refreshToken !== "string" || typeof idToken !== "string" || accessToken !== void 0 && typeof accessToken !== "string" || scope !== void 0 && typeof scope !== "string" || !isUsableEpochMs(createdAt) || !isUsableEpochMs(updatedAt) || !isUsableEpochMs(now) || expiresAt !== void 0 && (!isUsableEpochMs(expiresAt) || expiresAt <= now))
1866
+ throw invalidVaultSession();
1867
+ const provider = snapshotProvider(rawProvider);
1868
+ const binding = snapshotDpopBinding(rawBinding);
1869
+ if (binding !== void 0 && Object.keys(rawBinding).length !== 2) throw invalidVaultSession();
1870
+ return Object.freeze({
1871
+ sessionId,
1872
+ logicalSessionId,
1873
+ subject,
1874
+ providerSessionId,
1875
+ provider,
1876
+ ...binding === void 0 ? {} : { deviceBinding: binding },
1877
+ refreshToken,
1878
+ idToken,
1879
+ accessToken,
1880
+ scope,
1881
+ expiresAt,
1882
+ createdAt,
1883
+ updatedAt,
1884
+ ...user === void 0 ? {} : { user: copyPortableData(user) },
1885
+ ...metadata === void 0 ? {} : { metadata: copyPortableData(metadata) }
1886
+ });
1887
+ };
1888
+ var assertVaultSessionMatches = (actual, original) => {
1889
+ if (actual.sessionId !== original.sessionId || actual.logicalSessionId !== original.logicalSessionId || actual.subject !== original.subject || actual.providerSessionId !== original.providerSessionId || actual.provider?.issuer !== original.provider?.issuer || actual.provider?.clientId !== original.provider?.clientId || actual.deviceBinding?.jkt !== original.deviceBinding?.jkt || actual.expiresAt !== original.expiresAt || actual.createdAt !== original.createdAt || actual.updatedAt !== original.updatedAt || actual.refreshToken !== original.refreshToken || actual.idToken !== original.idToken || actual.accessToken !== original.accessToken || actual.scope !== original.scope)
1890
+ throw invalidVaultSession();
1891
+ };
1892
+ var snapshotVaultExchangeCode = (record, expectedCode, now) => {
1893
+ const { code, sessionId, returnTo, createdAt, expiresAt } = record;
1894
+ if (code !== expectedCode || typeof sessionId !== "string" || !sessionId || returnTo !== void 0 && typeof returnTo !== "string" || !isUsableEpochMs(createdAt) || !isUsableEpochMs(expiresAt) || !isUsableEpochMs(now) || expiresAt <= now)
1895
+ throw invalidExchangeCode();
1896
+ const match = snapshotRecordBindingMatch(record);
1897
+ return Object.freeze({
1898
+ code,
1899
+ sessionId,
1900
+ returnTo,
1901
+ createdAt,
1902
+ expiresAt,
1903
+ match,
1904
+ ...match.deviceBinding === null ? {} : { deviceBinding: match.deviceBinding },
1905
+ ...match.browserBindingHash === null ? {} : { browserBindingHash: match.browserBindingHash }
1906
+ });
1907
+ };
1908
+ var assertVaultExchangeCodeMatches = (actual, original) => {
1909
+ if (actual.code !== original.code || actual.sessionId !== original.sessionId || actual.returnTo !== original.returnTo || actual.createdAt !== original.createdAt || actual.expiresAt !== original.expiresAt || !recordBindingMatches(actual.match, original.match))
1910
+ throw invalidExchangeCode();
1911
+ };
1912
+ var assertVaultExchangeSessionBinding = (code, session) => {
1913
+ if (code.sessionId !== session.sessionId || code.deviceBinding?.jkt !== session.deviceBinding?.jkt)
1914
+ throw invalidVaultSession();
1915
+ };
1916
+ var snapshotVaultRevocationContext = (context) => {
1917
+ const { logicalSessionId, provider, deviceBinding } = context;
1918
+ if (typeof logicalSessionId !== "string" || !logicalSessionId) throw invalidVaultSession();
1919
+ const identity = snapshotProvider(provider);
1920
+ const binding = snapshotDpopBinding(deviceBinding);
1921
+ if (binding !== void 0 && Object.keys(deviceBinding).length !== 2) throw invalidVaultSession();
1922
+ return Object.freeze({
1923
+ logicalSessionId,
1924
+ ...identity === void 0 ? {} : { provider: identity },
1925
+ ...binding === void 0 ? {} : { deviceBinding: binding }
1926
+ });
1927
+ };
1928
+ var assertVaultRevocationContextMatches = (actual, original) => {
1929
+ if (actual.logicalSessionId !== original.logicalSessionId || actual.provider?.issuer !== original.provider?.issuer || actual.provider?.clientId !== original.provider?.clientId || actual.deviceBinding?.jkt !== original.deviceBinding?.jkt) {
1930
+ throw invalidVaultSession();
1931
+ }
1932
+ };
1933
+
1934
+ // src/token-issuance.ts
1935
+ var createExchangeResponse = (options, session, issuedToken) => ({
1936
+ sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
1937
+ user: copyVaultUserProfile(session.user),
1938
+ accessToken: issuedToken.accessToken,
1939
+ expiresIn: issuedToken.expiresIn,
1940
+ tokenType: issuedToken.tokenType
1941
+ });
1942
+ var isCompactSignedJwt = (token) => {
1943
+ const parts = token.split(".");
1944
+ return parts.length === 3 && parts.every(
1945
+ (part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
1946
+ );
1947
+ };
1948
+ var assertIssuedTokenConfirmation = (accessToken, jkt) => {
1949
+ if (!isCompactSignedJwt(accessToken)) {
1950
+ throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
1951
+ }
1952
+ let header;
1953
+ let claims;
1954
+ try {
1955
+ header = (0, import_jose3.decodeProtectedHeader)(accessToken);
1956
+ claims = (0, import_jose3.decodeJwt)(accessToken);
1957
+ } catch {
1958
+ throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
1959
+ }
1960
+ if (typeof header.alg !== "string" || header.alg.length === 0 || header.alg === "none" || header.b64 === false) {
1961
+ throw new TypeError("tokenIssuer.issue accessToken must use a signed JWT algorithm for bound issuance.");
1962
+ }
1963
+ const cnf = claims.cnf;
1964
+ if (typeof cnf !== "object" || cnf === null || Array.isArray(cnf) || cnf.jkt !== jkt) {
1965
+ throw new TypeError("tokenIssuer.issue accessToken cnf.jkt must match the verified device binding.");
1966
+ }
1967
+ };
1968
+ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
1969
+ const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
1970
+ if (!options.tokenIssuer) return {};
1971
+ const logicalSessionId = session.logicalSessionId ?? session.sessionId;
1972
+ try {
1973
+ const issuerSession = withSessionDeviceBinding(copyVaultSession(session, true), binding);
1974
+ const result = await options.tokenIssuer.issue({
1975
+ req,
1976
+ res,
1977
+ session: issuerSession,
1978
+ ...binding === void 0 ? {} : { deviceBinding: Object.freeze({ ...binding }) }
1979
+ });
1980
+ if (typeof result !== "object" || result === null || Array.isArray(result)) {
1981
+ throw new TypeError("tokenIssuer.issue must return a token result object.");
1982
+ }
1983
+ const { accessToken, expiresIn, tokenType } = result;
1984
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
1985
+ throw new TypeError("tokenIssuer.issue accessToken must be a nonempty string.");
1986
+ }
1987
+ if (typeof expiresIn !== "number" || !Number.isSafeInteger(expiresIn) || expiresIn < 0) {
1988
+ throw new TypeError("tokenIssuer.issue expiresIn must be a finite nonnegative safe integer.");
1989
+ }
1990
+ if (tokenType !== void 0 && tokenType !== "Bearer" && tokenType !== "DPoP") {
1991
+ throw new TypeError("tokenIssuer.issue tokenType must be the exact Bearer or DPoP literal when provided.");
1992
+ }
1993
+ if (binding === void 0) {
1994
+ if (tokenType !== void 0 && tokenType !== "Bearer") {
1995
+ throw new TypeError("tokenIssuer.issue tokenType must be Bearer when provided for unbound issuance.");
1996
+ }
1997
+ } else {
1998
+ if (tokenType !== "DPoP") throw new TypeError("tokenIssuer.issue tokenType must be DPoP for bound issuance.");
1999
+ assertIssuedTokenConfirmation(accessToken, binding.jkt);
2000
+ }
2001
+ return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
2002
+ } catch (error) {
2003
+ await options.storeProvider.deleteSessionsByLogicalSessionId({ logicalSessionId });
2004
+ if (usesCookieTransport(options)) clearSessionCookie(res, options);
2005
+ throw error;
2006
+ }
2007
+ };
2008
+
2009
+ // src/token-validation.ts
2010
+ var import_jose4 = require("jose");
2011
+
2012
+ // src/access-token-confirmation.ts
2013
+ var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
2014
+ var snapshotJkt = (value) => {
2015
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
2016
+ throw invalidAccessToken("Access token confirmation must be a plain jkt object.");
2017
+ }
2018
+ const prototype = Object.getPrototypeOf(value);
2019
+ const keys = Reflect.ownKeys(value);
2020
+ if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
2021
+ throw invalidAccessToken("Access token confirmation contains unsupported fields.");
2022
+ }
2023
+ const { jkt } = value;
2024
+ if (!isCanonicalDpopJkt(jkt))
2025
+ throw invalidAccessToken("Access token confirmation is not a canonical SHA-256 thumbprint.");
2026
+ return Object.freeze({ jkt });
2027
+ };
2028
+ var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
2029
+ var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
2030
+
1088
2031
  // src/token-validation.ts
1089
- var import_jose2 = require("jose");
1090
2032
  var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
1091
2033
  var validateBackchannelLogoutTokenTyp = (typ) => {
1092
2034
  if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
@@ -1145,7 +2087,7 @@ async function verifyIdToken(metadata, idToken, nonce, options = {}) {
1145
2087
  );
1146
2088
  }
1147
2089
  const jwks = resolveJwks(metadata.jwksUri, options);
1148
- const result = await (0, import_jose2.jwtVerify)(idToken, jwks, {
2090
+ const result = await (0, import_jose4.jwtVerify)(idToken, jwks, {
1149
2091
  audience: metadata.clientId,
1150
2092
  issuer: metadata.issuer
1151
2093
  });
@@ -1189,7 +2131,7 @@ async function verifyBackchannelLogoutToken(metadata, logoutToken, options = {})
1189
2131
  );
1190
2132
  }
1191
2133
  const jwks = resolveJwks(metadata.jwksUri, options);
1192
- const result = await (0, import_jose2.jwtVerify)(logoutToken, jwks, {
2134
+ const result = await (0, import_jose4.jwtVerify)(logoutToken, jwks, {
1193
2135
  audience: metadata.clientId,
1194
2136
  issuer: metadata.issuer
1195
2137
  });
@@ -1234,17 +2176,35 @@ async function verifyBackchannelLogoutToken(metadata, logoutToken, options = {})
1234
2176
  return claims;
1235
2177
  }
1236
2178
  function createOidcVaultJwtAccessTokenValidator(options) {
1237
- return {
2179
+ const {
2180
+ key: configuredKey,
2181
+ issuer,
2182
+ audience: configuredAudience,
2183
+ algorithms: configuredAlgorithms,
2184
+ mapClaims
2185
+ } = options;
2186
+ const key = configuredKey instanceof Uint8Array ? Uint8Array.from(configuredKey) : "kty" in configuredKey ? { ...configuredKey, ...configuredKey.key_ops ? { key_ops: [...configuredKey.key_ops] } : {} } : configuredKey;
2187
+ const audience = Array.isArray(configuredAudience) ? [...configuredAudience] : configuredAudience;
2188
+ const algorithms = configuredAlgorithms === void 0 ? void 0 : [...configuredAlgorithms];
2189
+ const validateJwt = async (token) => {
2190
+ const result = await (0, import_jose4.jwtVerify)(token, key, { issuer, audience, algorithms });
2191
+ const claims = result.payload;
2192
+ const confirmation = snapshotVerifiedJwtConfirmation(claims);
2193
+ const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
2194
+ const { subject, sessionId, scope, claims: mappedClaims } = mapped;
2195
+ return { subject, sessionId, scope, claims: mappedClaims, confirmation };
2196
+ };
2197
+ return Object.freeze({
1238
2198
  async validate(token) {
1239
- const result = await (0, import_jose2.jwtVerify)(token, options.key, {
1240
- issuer: options.issuer,
1241
- audience: options.audience,
1242
- algorithms: options.algorithms
1243
- });
1244
- const claims = result.payload;
1245
- return options.mapClaims ? options.mapClaims(claims) : defaultJwtClaimsMapper(claims);
2199
+ const { confirmation, ...mapped } = await validateJwt(token);
2200
+ return confirmation === null ? mapped : { ...mapped, confirmation };
2201
+ },
2202
+ async validateWithRequest({
2203
+ token
2204
+ }) {
2205
+ return validateJwt(token);
1246
2206
  }
1247
- };
2207
+ });
1248
2208
  }
1249
2209
 
1250
2210
  // src/types.ts
@@ -1254,6 +2214,235 @@ var OidcVaultStoreConflictError = class extends Error {
1254
2214
  this.name = "OidcVaultStoreConflictError";
1255
2215
  }
1256
2216
  };
2217
+ var OidcVaultDpopReplayCapacityError = class extends Error {
2218
+ constructor(message = "OIDC vault DPoP replay store capacity is exhausted.") {
2219
+ super(message);
2220
+ this.name = "OidcVaultDpopReplayCapacityError";
2221
+ }
2222
+ };
2223
+
2224
+ // src/dpop-replay.ts
2225
+ var import_node_crypto5 = require("crypto");
2226
+ var isPrintableIdentifier = (value) => typeof value === "string" && /^[\x20-\x7e]{1,128}$/.test(value);
2227
+ var normalizeNamespaceBasePath = (origin, value) => {
2228
+ if (value !== void 0 && typeof value !== "string")
2229
+ throw new TypeError("DPoP vault basePath must be a static path.");
2230
+ const mounted = !value || value === "/" ? DEFAULT_OIDC_VAULT_BASE_PATH : `/${value.replace(/^\/+|\/+$/g, "")}`;
2231
+ if (/[\s\\?#]/.test(mounted) || /%(?![0-9a-f]{2})/i.test(mounted)) {
2232
+ throw new TypeError("DPoP vault basePath must be a static path without query, fragment or malformed escapes.");
2233
+ }
2234
+ const path = mounted.replace(/%([0-9a-f]{2})/gi, (_, hex) => {
2235
+ const character = String.fromCharCode(Number.parseInt(hex, 16));
2236
+ return /^[A-Za-z0-9._~-]$/.test(character) ? character : `%${hex.toUpperCase()}`;
2237
+ });
2238
+ const normalized = new URL(`${origin}${path}`).pathname;
2239
+ return normalized.replace(/\/+$/g, "") || "/";
2240
+ };
2241
+ var resolveDpopEffectiveNamespace = (space) => {
2242
+ if (space?.type === "vault") {
2243
+ const { backendOrigin, basePath, issuer, clientId } = space;
2244
+ const origin = resolveDeviceBindingOrigin(backendOrigin);
2245
+ if (typeof issuer !== "string" || issuer.length === 0 || typeof clientId !== "string" || clientId.length === 0) {
2246
+ throw new TypeError("DPoP vault namespace requires resolved issuer and clientId.");
2247
+ }
2248
+ return Object.freeze(["vault", origin, normalizeNamespaceBasePath(origin, basePath), issuer, clientId]);
2249
+ }
2250
+ if (space?.type === "api") {
2251
+ const { publicOrigin, replayNamespace } = space;
2252
+ if (!isPrintableIdentifier(replayNamespace)) {
2253
+ throw new TypeError("DPoP replayNamespace must contain 1 through 128 printable ASCII bytes.");
2254
+ }
2255
+ return Object.freeze(["api", resolveDeviceBindingOrigin(publicOrigin, "publicOrigin"), replayNamespace]);
2256
+ }
2257
+ throw new TypeError("DPoP replay policy requires a vault or API protection space.");
2258
+ };
2259
+ var createDpopReplayKey = (effectiveNamespace, jkt, jti) => {
2260
+ if (!isCanonicalDpopJkt(jkt) || !isPrintableIdentifier(jti)) {
2261
+ throw new TypeError("DPoP replay key requires a canonical thumbprint and a bounded printable JTI.");
2262
+ }
2263
+ return `dpop:v1:${(0, import_node_crypto5.createHash)("sha256").update(JSON.stringify([effectiveNamespace, jkt, jti]), "utf8").digest("base64url")}`;
2264
+ };
2265
+ var getDpopReplayExpiresAt = (input) => {
2266
+ const { iat, now, proofMaxAgeSeconds: age, clockSkewSeconds: skew } = input;
2267
+ if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || !Number.isSafeInteger(now) || now < 0 || !Number.isInteger(age) || age < 1 || age > 300 || !Number.isInteger(skew) || skew < 0 || skew > 30)
2268
+ return null;
2269
+ const issuedAt = iat * 1e3;
2270
+ const expiresAt = (iat + age + skew) * 1e3;
2271
+ if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(expiresAt) || issuedAt - now > skew * 1e3 || expiresAt <= now || expiresAt - now > 36e4)
2272
+ return null;
2273
+ return expiresAt;
2274
+ };
2275
+ var invalidProof2 = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_DPOP_PROOF", diagnostic, "DPoP proof validation failed.");
2276
+ var replayUnavailable = (cause) => {
2277
+ const error = new OidcVaultHttpError(
2278
+ 503,
2279
+ "OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE",
2280
+ "DPoP replay admission failed.",
2281
+ "DPoP replay protection is unavailable."
2282
+ );
2283
+ Object.defineProperty(error, "cause", { value: cause, configurable: true });
2284
+ return error;
2285
+ };
2286
+ var createDpopReplayPolicy = (options) => {
2287
+ const { protectionSpace, proofOptions, replayStore, now: configuredNow } = options;
2288
+ const policy = resolveDeviceBindingOptions(proofOptions);
2289
+ if (!policy) throw new TypeError("DPoP replay policy requires enabled proof options.");
2290
+ if (typeof replayStore?.reserveDpopProof !== "function")
2291
+ throw new TypeError("DPoP replay policy requires a replay store.");
2292
+ if (configuredNow !== void 0 && typeof configuredNow !== "function")
2293
+ throw new TypeError("DPoP replay clock must be callable.");
2294
+ const now = configuredNow ?? Date.now;
2295
+ const effectiveNamespace = resolveDpopEffectiveNamespace(protectionSpace);
2296
+ const noncePolicy = policy.nonce === false ? void 0 : createDpopNoncePolicy({
2297
+ ...policy.nonce,
2298
+ namespace: JSON.stringify(effectiveNamespace)
2299
+ });
2300
+ return Object.freeze({
2301
+ effectiveNamespace,
2302
+ async verifyAndReserve(verifyBeforeReplay) {
2303
+ const verified = await verifyBeforeReplay();
2304
+ if (typeof verified !== "object" || verified === null || Array.isArray(verified)) {
2305
+ throw invalidProof2("DPoP verifier did not return a proof verification result.");
2306
+ }
2307
+ const { binding: verifiedBinding, iat, jti, nonce } = verified;
2308
+ if (typeof verifiedBinding !== "object" || verifiedBinding === null || Array.isArray(verifiedBinding)) {
2309
+ throw invalidProof2("DPoP verifier did not return a verified key binding.");
2310
+ }
2311
+ const { type, jkt, alg } = verifiedBinding;
2312
+ if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti)) {
2313
+ throw invalidProof2("DPoP verified key/algorithm or signed JTI shape is invalid.");
2314
+ }
2315
+ const binding = Object.freeze({ type, jkt, alg });
2316
+ let checkedNow;
2317
+ try {
2318
+ checkedNow = now();
2319
+ if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
2320
+ } catch (error) {
2321
+ throw replayUnavailable(error);
2322
+ }
2323
+ const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2324
+ if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
2325
+ if (noncePolicy) {
2326
+ if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
2327
+ return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
2328
+ }
2329
+ } else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
2330
+ throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
2331
+ }
2332
+ let reserved;
2333
+ try {
2334
+ reserved = await replayStore.reserveDpopProof({
2335
+ replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
2336
+ expiresAt
2337
+ });
2338
+ if (typeof reserved !== "boolean")
2339
+ throw new TypeError("DPoP replay store returned a non-boolean admission result.");
2340
+ } catch (error) {
2341
+ throw replayUnavailable(error);
2342
+ }
2343
+ if (!reserved) throw invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2344
+ return Object.freeze({ type: "accepted", binding, expiresAt });
2345
+ }
2346
+ });
2347
+ };
2348
+ var challenge = (error, algorithms) => {
2349
+ if (algorithms.length === 0 || !algorithms.every(isDpopAlgorithm))
2350
+ throw new TypeError("DPoP challenge requires a resolved algorithm allowlist.");
2351
+ return `DPoP error="${error}", algs="${[...new Set(algorithms)].join(" ")}"`;
2352
+ };
2353
+ var toDpopReplayErrorResponse = (error, algorithms) => {
2354
+ const { status, code, message } = toErrorPayload(error);
2355
+ return Object.freeze({
2356
+ status,
2357
+ body: Object.freeze({ code, message }),
2358
+ headers: Object.freeze({
2359
+ "Cache-Control": "no-store",
2360
+ ...status === 401 && (code === "OIDC_VAULT_INVALID_DPOP_PROOF" || code === "OIDC_VAULT_DPOP_REQUIRED") ? { "WWW-Authenticate": challenge("invalid_dpop_proof", algorithms) } : {}
2361
+ })
2362
+ });
2363
+ };
2364
+ var toDpopNonceChallengeResponse = (result, endpoint, algorithms) => {
2365
+ const nonce = result?.nonce;
2366
+ if (result?.type !== "nonce-challenge" || typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || !/^v1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/.test(nonce))
2367
+ throw new TypeError("DPoP nonce response requires a bounded server-issued challenge.");
2368
+ if (endpoint !== "vault-post" && endpoint !== "api")
2369
+ throw new TypeError("DPoP nonce response requires an API or vault POST endpoint.");
2370
+ return Object.freeze({
2371
+ status: endpoint === "api" ? 401 : 400,
2372
+ body: Object.freeze({ code: "OIDC_VAULT_USE_DPOP_NONCE", message: "A fresh DPoP nonce is required." }),
2373
+ headers: Object.freeze({
2374
+ "Cache-Control": "no-store",
2375
+ "DPoP-Nonce": nonce,
2376
+ ...endpoint === "api" ? { "WWW-Authenticate": challenge("use_dpop_nonce", algorithms) } : {}
2377
+ })
2378
+ });
2379
+ };
2380
+
2381
+ // src/vault-route-proof.ts
2382
+ var captureVaultRouteProof = (req) => Object.freeze({
2383
+ header: readDpopRawHeader(req, "dpop"),
2384
+ method: req.method,
2385
+ originalUrl: req.originalUrl
2386
+ });
2387
+ var VaultDpopNonceChallengeError = class extends OidcVaultHttpError {
2388
+ constructor(response) {
2389
+ super(400, "OIDC_VAULT_USE_DPOP_NONCE", "A fresh DPoP nonce is required.");
2390
+ this.response = response;
2391
+ }
2392
+ };
2393
+ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) => {
2394
+ const policy = options.deviceBinding;
2395
+ if (policy === void 0) return void 0;
2396
+ assertDeviceBindingStoreCapabilities(options.storeProvider);
2397
+ const targetFor = createDpopRequestTargetResolver({ publicOrigin: backendOrigin });
2398
+ const replay = createDpopReplayPolicy({
2399
+ // The config resolver requires both identifiers in manual/discovery modes.
2400
+ protectionSpace: { type: "vault", backendOrigin, basePath, issuer: config.issuer, clientId: config.clientId },
2401
+ proofOptions: policy,
2402
+ replayStore: options.storeProvider,
2403
+ now: options.now
2404
+ });
2405
+ const admit = async (request, proof, expectedJkt) => {
2406
+ const targetUrl = targetFor(request.originalUrl);
2407
+ const result = await replay.verifyAndReserve(
2408
+ () => verifyDpopProof({
2409
+ proof,
2410
+ method: request.method,
2411
+ targetUrl,
2412
+ proofOptions: policy,
2413
+ expectedJkt
2414
+ })
2415
+ );
2416
+ if (result.type === "nonce-challenge") {
2417
+ throw new VaultDpopNonceChallengeError(toDpopNonceChallengeResponse(result, "vault-post", policy.algorithms));
2418
+ }
2419
+ return result.binding;
2420
+ };
2421
+ return Object.freeze({
2422
+ async initiate(request) {
2423
+ const proof = extractDpopProof(request.header);
2424
+ if (proof === void 0) {
2425
+ assertDeviceBindingPolicy(policy, void 0);
2426
+ return void 0;
2427
+ }
2428
+ return admit(request, proof);
2429
+ },
2430
+ async verify(request, originalBinding) {
2431
+ const binding = snapshotDpopBinding(originalBinding);
2432
+ assertDeviceBindingPolicy(policy, binding);
2433
+ const proof = extractDpopProof(request.header);
2434
+ const verified = proof === void 0 ? void 0 : await admit(request, proof, binding?.jkt);
2435
+ return resolveVerifiedDeviceBinding(policy, binding, verified);
2436
+ }
2437
+ });
2438
+ };
2439
+ var toVaultRouteErrorResponse = (error, policy) => error instanceof VaultDpopNonceChallengeError ? error.response : toDpopReplayErrorResponse(error, policy?.algorithms ?? ["ES256"]);
2440
+ var sendVaultRoutePolicyResponse = (res, response) => {
2441
+ res.removeHeader("WWW-Authenticate");
2442
+ res.removeHeader("DPoP-Nonce");
2443
+ for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
2444
+ res.status(response.status).json(response.body);
2445
+ };
1257
2446
 
1258
2447
  // src/access-token-middleware.ts
1259
2448
  var extractBearerToken = (authorizationHeader) => {
@@ -1270,72 +2459,212 @@ var extractBearerToken = (authorizationHeader) => {
1270
2459
  }
1271
2460
  return token;
1272
2461
  };
1273
- var setBearerChallengeHeader = (res) => {
1274
- res.setHeader("WWW-Authenticate", "Bearer");
1275
- };
1276
- var notifyBearerMiddlewareError = async (options, context) => {
2462
+ var notifyMiddlewareError = async (onError, context) => {
1277
2463
  try {
1278
- await options.onError?.(context);
2464
+ await onError?.(context);
1279
2465
  } catch {
1280
2466
  }
1281
2467
  };
2468
+ var schemeOf = (value) => {
2469
+ const scheme = value?.trim().split(/\s+/, 1)[0]?.toLowerCase();
2470
+ return scheme === "bearer" ? "Bearer" : scheme === "dpop" ? "DPoP" : void 0;
2471
+ };
2472
+ var mappedFields = (result) => {
2473
+ if (typeof result !== "object" || result === null || Array.isArray(result))
2474
+ throw invalidAccessToken("Access token validator result is invalid.");
2475
+ const { subject, sessionId, scope, claims } = result;
2476
+ if (typeof subject !== "string" || subject.length === 0)
2477
+ throw invalidAccessToken("Access token validator result is missing subject.");
2478
+ return { subject, sessionId, scope, claims };
2479
+ };
1282
2480
  function createOidcVaultAccessTokenMiddleware(options) {
2481
+ const { validator, deviceBinding: configuredBinding, onAuthContext, onError } = options;
2482
+ const policy = resolveDeviceBindingOptions(configuredBinding);
2483
+ const validate = validator.validate.bind(validator);
2484
+ const requestAware = policy ? validator.validateWithRequest : void 0;
2485
+ if (policy && typeof requestAware !== "function")
2486
+ throw new TypeError("deviceBinding requires validator.validateWithRequest.");
2487
+ const validateWithRequest = requestAware?.bind(validator);
2488
+ const apiOptions = policy ? (() => {
2489
+ const { publicOrigin, publicPathPrefix, replayNamespace, replayStore, now } = configuredBinding;
2490
+ return {
2491
+ publicOrigin: resolveDeviceBindingOrigin(publicOrigin, "publicOrigin"),
2492
+ publicPathPrefix,
2493
+ replayNamespace,
2494
+ replayStore,
2495
+ now
2496
+ };
2497
+ })() : void 0;
2498
+ const targetFor = apiOptions ? createDpopRequestTargetResolver(apiOptions) : void 0;
2499
+ const replay = policy ? createDpopReplayPolicy({
2500
+ protectionSpace: {
2501
+ type: "api",
2502
+ publicOrigin: apiOptions.publicOrigin,
2503
+ replayNamespace: apiOptions.replayNamespace
2504
+ },
2505
+ proofOptions: policy,
2506
+ replayStore: apiOptions.replayStore,
2507
+ now: apiOptions.now
2508
+ }) : void 0;
2509
+ const algorithms = policy?.algorithms ?? ["ES256"];
2510
+ const algs = algorithms.join(" ");
2511
+ const dpopChallenge = (error) => `DPoP${error ? ` error="${error}",` : ""} algs="${algs}"`;
2512
+ const proofChallenge = dpopChallenge("invalid_dpop_proof");
1283
2513
  return async (req, res, next) => {
1284
2514
  let token;
2515
+ let scheme;
2516
+ let nonceResponse;
2517
+ const authenticatedRequest = req;
2518
+ delete authenticatedRequest.auth;
2519
+ if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
2520
+ const prepareError = (error, hookFailure = false) => {
2521
+ const response = nonceResponse ?? (error instanceof OidcVaultHttpError ? toDpopReplayErrorResponse(error, algorithms) : {
2522
+ status: hookFailure ? 500 : 401,
2523
+ body: hookFailure ? { code: "OIDC_VAULT_AUTH_CONTEXT_FAILED", message: "Auth context hook failed." } : { code: "OIDC_VAULT_INVALID_ACCESS_TOKEN", message: "Access token validation failed." },
2524
+ headers: { "Cache-Control": "no-store" }
2525
+ });
2526
+ const headers = { ...response.headers };
2527
+ if (response.status === 401 && headers["WWW-Authenticate"] === void 0) {
2528
+ const code = response.body.code;
2529
+ const challenge2 = code === "OIDC_VAULT_DPOP_REQUIRED" || code === "OIDC_VAULT_INVALID_DPOP_PROOF" ? proofChallenge : !policy ? "Bearer" : code === "OIDC_VAULT_MISSING_ACCESS_TOKEN" ? policy.mode === "required" ? dpopChallenge() : `Bearer, ${dpopChallenge()}` : scheme === "DPoP" || !scheme && policy.mode === "required" ? dpopChallenge("invalid_token") : 'Bearer error="invalid_token"';
2530
+ headers["WWW-Authenticate"] = challenge2;
2531
+ }
2532
+ return Object.freeze({
2533
+ status: response.status,
2534
+ body: Object.freeze({ ...response.body }),
2535
+ headers: Object.freeze(headers)
2536
+ });
2537
+ };
2538
+ const sendError = (response) => {
2539
+ res.removeHeader("WWW-Authenticate");
2540
+ res.removeHeader("DPoP-Nonce");
2541
+ for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
2542
+ res.status(response.status).json(response.body);
2543
+ };
1285
2544
  try {
1286
- token = extractBearerToken(req.get("authorization"));
1287
- const validationResult = await options.validator.validate(token);
2545
+ let result;
2546
+ let confirmation;
2547
+ let deviceBinding;
2548
+ if (!policy) {
2549
+ token = extractBearerToken(req.get("authorization"));
2550
+ scheme = "Bearer";
2551
+ result = await validate(token);
2552
+ delete authenticatedRequest.auth;
2553
+ const reported = result?.confirmation;
2554
+ confirmation = reported === void 0 ? void 0 : snapshotAccessTokenConfirmation(reported);
2555
+ result = mappedFields(result);
2556
+ delete authenticatedRequest.auth;
2557
+ if (confirmation)
2558
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2559
+ } else {
2560
+ const authorization = readDpopRawHeader(req, "authorization");
2561
+ scheme = schemeOf(authorization.value);
2562
+ if (authorization.count === 0 || authorization.count === 1 && !authorization.value?.trim()) {
2563
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_MISSING_ACCESS_TOKEN", "Missing access token.");
2564
+ }
2565
+ const credential = authorization.value?.trim().split(/\s+/);
2566
+ if (authorization.count !== 1 || !scheme || credential?.length !== 2 || !credential[1] || !/^[\x21-\x7e]+$/.test(credential[1]) || credential[1].includes(",")) {
2567
+ throw new OidcVaultHttpError(
2568
+ 401,
2569
+ "OIDC_VAULT_INVALID_AUTHORIZATION_HEADER",
2570
+ "Authorization header must use one Bearer or DPoP credential."
2571
+ );
2572
+ }
2573
+ token = credential[1];
2574
+ const proofHeader = readDpopRawHeader(req, "dpop");
2575
+ const { method, originalUrl } = req;
2576
+ result = await validateWithRequest(Object.freeze({ token, scheme, req }));
2577
+ delete authenticatedRequest.auth;
2578
+ confirmation = snapshotAccessTokenConfirmation(result?.confirmation);
2579
+ result = mappedFields(result);
2580
+ delete authenticatedRequest.auth;
2581
+ if (confirmation && scheme !== "DPoP")
2582
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2583
+ if (!confirmation && scheme === "DPoP")
2584
+ throw invalidAccessToken("DPoP presentation of a verified unbound token is invalid.");
2585
+ if (!confirmation && policy.mode === "required") {
2586
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DEVICE_BINDING_REQUIRED", "A device-bound login is required.");
2587
+ }
2588
+ const proof = extractDpopProof(proofHeader);
2589
+ if (!proof && confirmation)
2590
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2591
+ if (proof) {
2592
+ const targetUrl = targetFor(originalUrl);
2593
+ const admission = await replay.verifyAndReserve(
2594
+ () => verifyDpopProof({
2595
+ proof,
2596
+ method,
2597
+ targetUrl,
2598
+ proofOptions: policy,
2599
+ accessToken: token,
2600
+ expectedJkt: confirmation?.jkt
2601
+ })
2602
+ );
2603
+ if (admission.type === "nonce-challenge") {
2604
+ nonceResponse = toDpopNonceChallengeResponse(admission, "api", policy.algorithms);
2605
+ throw new OidcVaultHttpError(401, "OIDC_VAULT_USE_DPOP_NONCE", "A fresh DPoP nonce is required.");
2606
+ }
2607
+ if (confirmation) deviceBinding = admission.binding;
2608
+ }
2609
+ }
2610
+ const authenticatedToken = token;
1288
2611
  const auth = {
1289
- token,
1290
- ...validationResult
2612
+ ...result,
2613
+ token: authenticatedToken,
2614
+ ...confirmation === void 0 ? {} : { confirmation },
2615
+ ...deviceBinding ? { deviceBinding } : {}
2616
+ };
2617
+ const restoreAuth = () => {
2618
+ const { subject, sessionId, scope, claims } = auth;
2619
+ return {
2620
+ subject,
2621
+ sessionId,
2622
+ scope,
2623
+ claims,
2624
+ token: authenticatedToken,
2625
+ ...confirmation === void 0 ? {} : { confirmation },
2626
+ ...deviceBinding ? { deviceBinding } : {}
2627
+ };
1291
2628
  };
1292
- const authenticatedRequest = req;
1293
2629
  authenticatedRequest.auth = auth;
1294
2630
  try {
1295
- await options.onAuthContext?.({ req: authenticatedRequest, res, auth });
2631
+ await onAuthContext?.({ req: authenticatedRequest, res, auth });
2632
+ authenticatedRequest.auth = restoreAuth();
1296
2633
  } catch (hookError) {
2634
+ const response = prepareError(hookError, true);
2635
+ delete authenticatedRequest.auth;
2636
+ let errorAuth;
2637
+ try {
2638
+ errorAuth = restoreAuth();
2639
+ } catch {
2640
+ errorAuth = {
2641
+ ...result,
2642
+ token: authenticatedToken,
2643
+ ...confirmation === void 0 ? {} : { confirmation },
2644
+ ...deviceBinding ? { deviceBinding } : {}
2645
+ };
2646
+ }
2647
+ await notifyMiddlewareError(onError, { error: hookError, req, res, token, auth: errorAuth });
1297
2648
  delete authenticatedRequest.auth;
1298
- await notifyBearerMiddlewareError(options, { error: hookError, req, res, token, auth });
1299
2649
  if (res.headersSent) {
1300
2650
  next(hookError);
1301
2651
  return;
1302
2652
  }
1303
- if (hookError instanceof OidcVaultHttpError) {
1304
- if (hookError.status === 401) {
1305
- setBearerChallengeHeader(res);
1306
- }
1307
- res.status(hookError.status).json({
1308
- code: hookError.code,
1309
- message: hookError.clientMessage
1310
- });
1311
- return;
1312
- }
1313
- res.status(500).json({
1314
- code: "OIDC_VAULT_AUTH_CONTEXT_FAILED",
1315
- message: "Auth context hook failed."
1316
- });
2653
+ sendError(response);
1317
2654
  return;
1318
2655
  }
2656
+ if (!res.headersSent) res.setHeader("Cache-Control", "no-store");
1319
2657
  next();
1320
2658
  } catch (error) {
1321
- await notifyBearerMiddlewareError(options, { error, req, res, ...token ? { token } : {} });
2659
+ const response = prepareError(error);
2660
+ delete authenticatedRequest.auth;
2661
+ await notifyMiddlewareError(onError, { error, req, res, ...token ? { token } : {} });
2662
+ delete authenticatedRequest.auth;
1322
2663
  if (res.headersSent) {
1323
2664
  next(error);
1324
2665
  return;
1325
2666
  }
1326
- if (error instanceof OidcVaultHttpError) {
1327
- setBearerChallengeHeader(res);
1328
- res.status(error.status).json({
1329
- code: error.code,
1330
- message: error.clientMessage
1331
- });
1332
- return;
1333
- }
1334
- setBearerChallengeHeader(res);
1335
- res.status(401).json({
1336
- code: "OIDC_VAULT_INVALID_ACCESS_TOKEN",
1337
- message: "Access token validation failed."
1338
- });
2667
+ sendError(response);
1339
2668
  }
1340
2669
  };
1341
2670
  }
@@ -1352,39 +2681,58 @@ var CREDENTIAL_RESPONSE_CACHE_CONTROL = "no-store";
1352
2681
  var applyCredentialResponseCachePolicy = (res) => {
1353
2682
  res.setHeader("Cache-Control", CREDENTIAL_RESPONSE_CACHE_CONTROL);
1354
2683
  };
1355
- var createOpaqueId = (prefix) => `${prefix}_${(0, import_node_crypto.randomBytes)(16).toString("base64url")}`;
1356
- var createPkceVerifier = () => (0, import_node_crypto.randomBytes)(32).toString("base64url");
1357
- var createPkceChallenge = (verifier) => (0, import_node_crypto.createHash)("sha256").update(verifier).digest("base64url");
2684
+ var createOpaqueId = (prefix) => `${prefix}_${(0, import_node_crypto6.randomBytes)(16).toString("base64url")}`;
2685
+ var createPkceVerifier = () => (0, import_node_crypto6.randomBytes)(32).toString("base64url");
2686
+ var createPkceChallenge = (verifier) => (0, import_node_crypto6.createHash)("sha256").update(verifier).digest("base64url");
1358
2687
  var getCallbackUri = (backendOrigin, basePath) => `${backendOrigin}${basePath}${OIDC_VAULT_ROUTE_PATHS.callback}`;
1359
2688
  var validateOidcVaultOptions = (options) => {
2689
+ const {
2690
+ deviceBinding: configuredDeviceBinding,
2691
+ fingerprintRecognition: configuredFingerprintRecognition,
2692
+ transactionCookie: configuredTransactionCookie,
2693
+ ...otherOptions
2694
+ } = options;
2695
+ const cookie = otherOptions.cookie ? { ...otherOptions.cookie } : void 0;
2696
+ const cookieOptions = { ...otherOptions, cookie };
1360
2697
  validateLifetimeOptions(options, getNow(options));
1361
- const backendOrigin = resolveBackendOrigin(options);
2698
+ const deviceBinding = resolveDeviceBindingOptions(configuredDeviceBinding);
2699
+ const fingerprintRecognition = resolveFingerprintRecognitionOptions(configuredFingerprintRecognition);
2700
+ const backendOrigin = deviceBinding === void 0 ? resolveBackendOrigin(options) : resolveDeviceBindingOrigin(options.backendOrigin);
2701
+ if (deviceBinding !== void 0) assertDeviceBindingStoreCapabilities(options.storeProvider);
2702
+ if (fingerprintRecognition !== void 0)
2703
+ assertDeviceBindingStoreCapabilities(options.storeProvider, "fingerprintRecognition");
1362
2704
  const frontendRedirectUri = resolveFrontendRedirectUri(options);
1363
2705
  validatePostLogoutRedirectUri(options);
1364
- validateCookieOptions(options);
2706
+ validateCookieOptions(cookieOptions);
2707
+ const transactionCookie = resolveTransactionCookieOptions(
2708
+ {
2709
+ ...cookieOptions,
2710
+ deviceBinding: configuredDeviceBinding,
2711
+ fingerprintRecognition,
2712
+ transactionCookie: configuredTransactionCookie
2713
+ },
2714
+ backendOrigin
2715
+ );
1365
2716
  const config = resolveOidcVaultConfig(options.config ? { ...options.config } : void 0);
1366
2717
  const configuredTrustedOrigins = resolveTrustedOrigins(options);
1367
- if (usesCrossSiteCookieTransport(options) && configuredTrustedOrigins.size === 0) {
2718
+ if (usesCrossSiteCookieTransport(cookieOptions) && configuredTrustedOrigins.size === 0) {
1368
2719
  throw new Error("trustedOrigins is required when using cross-site cookie transport.");
1369
2720
  }
1370
2721
  const trustedOrigins = new Set(configuredTrustedOrigins);
1371
2722
  trustedOrigins.add(backendOrigin);
1372
2723
  const resolvedOptions = {
1373
- ...options,
2724
+ ...otherOptions,
2725
+ deviceBinding,
2726
+ fingerprintRecognition,
2727
+ transactionCookie,
1374
2728
  ...frontendRedirectUri !== void 0 ? { frontendRedirectUri } : {},
1375
2729
  ...options.trustedOrigins ? { trustedOrigins: [...options.trustedOrigins] } : {},
1376
- ...options.cookie ? { cookie: { ...options.cookie } } : {},
2730
+ ...cookie === void 0 ? {} : { cookie },
1377
2731
  ...options.config ? { config: { ...options.config } } : {}
1378
2732
  };
1379
2733
  return { backendOrigin, config, trustedOrigins, resolvedOptions };
1380
2734
  };
1381
2735
  var isStoreConflictError = (error) => error instanceof OidcVaultStoreConflictError || typeof error === "object" && error !== null && "name" in error && error.name === "OidcVaultStoreConflictError";
1382
- var assertSessionIdentity = (session, config) => {
1383
- const provider = session.provider;
1384
- if (provider?.issuer !== void 0 && provider.issuer !== config.issuer || provider?.clientId !== void 0 && provider.clientId !== config.clientId) {
1385
- throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1386
- }
1387
- };
1388
2736
  var getSessionIdFromRequest = (req, options, action) => {
1389
2737
  if (usesCookieTransport(options)) {
1390
2738
  const cookieSessionId = getSessionIdFromCookie(req, options);
@@ -1404,13 +2752,6 @@ var getSessionIdFromRequest = (req, options, action) => {
1404
2752
  "OIDC_VAULT_MISSING_SESSION_ID"
1405
2753
  );
1406
2754
  };
1407
- var createExchangeResponse = (options, session, issuedToken) => ({
1408
- sessionId: usesCookieTransport(options) ? void 0 : session.sessionId,
1409
- user: session.user,
1410
- accessToken: issuedToken.accessToken,
1411
- expiresIn: issuedToken.expiresIn,
1412
- tokenType: issuedToken.tokenType
1413
- });
1414
2755
  var appendQueryParam = (url, name, value) => {
1415
2756
  if (value) {
1416
2757
  url.searchParams.set(name, value);
@@ -1447,8 +2788,7 @@ var buildBackchannelLogoutReplayKey = (input) => {
1447
2788
  return `v2:${encode(input.issuer ?? "")}:${encode(input.clientId ?? "")}:${encode(input.jti)}`;
1448
2789
  };
1449
2790
  var createHookContext = (route, req, res, session, metadata) => ({ route, req, res, session, metadata });
1450
- var resolveReturnTo = (req, options) => {
1451
- const rawReturnTo = req.query.returnTo;
2791
+ var resolveLoginReturnTo = (rawReturnTo, options) => {
1452
2792
  if (!isString(rawReturnTo)) {
1453
2793
  return void 0;
1454
2794
  }
@@ -1476,31 +2816,12 @@ var resolveReturnTo = (req, options) => {
1476
2816
  }
1477
2817
  return resolvedUrl.toString();
1478
2818
  };
2819
+ var resolveReturnTo = (req, options) => resolveLoginReturnTo(req.query.returnTo, options);
1479
2820
  var appendCodeToRedirectUri = (redirectUri, code) => {
1480
2821
  const url = new URL(redirectUri);
1481
2822
  url.searchParams.set("code", code);
1482
2823
  return url.toString();
1483
2824
  };
1484
- var withIssuedToken = async (req, res, options, session) => {
1485
- if (!options.tokenIssuer) {
1486
- return {};
1487
- }
1488
- const result = await options.tokenIssuer.issue({ req, res, session });
1489
- if (typeof result !== "object" || result === null || Array.isArray(result)) {
1490
- throw new TypeError("tokenIssuer.issue must return a token result object.");
1491
- }
1492
- const { accessToken, expiresIn, tokenType } = result;
1493
- if (typeof accessToken !== "string" || accessToken.length === 0) {
1494
- throw new TypeError("tokenIssuer.issue accessToken must be a nonempty string.");
1495
- }
1496
- if (typeof expiresIn !== "number" || !Number.isSafeInteger(expiresIn) || expiresIn < 0) {
1497
- throw new TypeError("tokenIssuer.issue expiresIn must be a finite nonnegative safe integer.");
1498
- }
1499
- if (tokenType !== void 0 && tokenType !== "Bearer") {
1500
- throw new TypeError("tokenIssuer.issue tokenType must be Bearer when provided.");
1501
- }
1502
- return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
1503
- };
1504
2825
  var resolveFrontendRedirectUri2 = (transaction, options) => {
1505
2826
  const redirectUri = transaction.returnTo ?? options.frontendRedirectUri;
1506
2827
  if (!redirectUri) {
@@ -1536,6 +2857,7 @@ async function callPostCommitHook(route, options, hook, req, res, session, metad
1536
2857
  }
1537
2858
  }
1538
2859
  async function handleRouteError(route, req, res, next, options, error) {
2860
+ const response = toVaultRouteErrorResponse(error, options.deviceBinding);
1539
2861
  try {
1540
2862
  await options.hooks?.onError?.({
1541
2863
  ...createHookContext(route, req, res),
@@ -1547,12 +2869,7 @@ async function handleRouteError(route, req, res, next, options, error) {
1547
2869
  next(error);
1548
2870
  return;
1549
2871
  }
1550
- applyCredentialResponseCachePolicy(res);
1551
- const payload = toErrorPayload(error);
1552
- res.status(payload.status).json({
1553
- code: payload.code,
1554
- message: payload.message
1555
- });
2872
+ sendVaultRoutePolicyResponse(res, response);
1556
2873
  }
1557
2874
  function createAsyncHandler(route, options, handler) {
1558
2875
  return async (req, res, next) => {
@@ -1578,6 +2895,7 @@ function createBodyParserErrorHandler() {
1578
2895
  };
1579
2896
  }
1580
2897
  var createLoginHandler = (options, config, backendOrigin, basePath) => createAsyncHandler("login", options, async (req, res) => {
2898
+ assertDeviceBindingPolicy(options.deviceBinding, void 0);
1581
2899
  const metadata = await resolveProviderMetadata(config, options);
1582
2900
  await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: metadata });
1583
2901
  const now = getNow(options);
@@ -1603,143 +2921,330 @@ var createLoginHandler = (options, config, backendOrigin, basePath) => createAsy
1603
2921
  });
1604
2922
  res.redirect(302, authorizationUrl);
1605
2923
  });
1606
- var createCallbackHandler = (options, config, backendOrigin, basePath) => createAsyncHandler("callback", options, async (req, res) => {
1607
- if (isString(req.query.error)) {
1608
- throw new OidcVaultHttpError(400, "OIDC_VAULT_CALLBACK_ERROR", req.query.error);
2924
+ var createLoginInitiationHandler = (options, config, trustedOrigins, backendOrigin, basePath, proofVerifier) => createAsyncHandler("login", options, async (req, res) => {
2925
+ const capturedProof = captureVaultRouteProof(req);
2926
+ const fingerprintRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
2927
+ assertLoginInitiationRequest(req, options, trustedOrigins);
2928
+ const body = getBody(req);
2929
+ if (Array.isArray(req.body) || body.returnTo !== void 0 && !isString(body.returnTo)) {
2930
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_REQUEST_BODY", "Login initiation request body is invalid.");
1609
2931
  }
1610
- const code = getRequiredString(
1611
- req.query.code,
1612
- "OIDC callback is missing the authorization code.",
1613
- "OIDC_VAULT_MISSING_CODE"
1614
- );
1615
- const state = getRequiredString(req.query.state, "OIDC callback is missing state.", "OIDC_VAULT_MISSING_STATE");
1616
- const transaction = await options.storeProvider.consumeAuthorizationTransaction(state);
1617
- if (!transaction) {
1618
- throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_STATE", "OIDC state is invalid or expired.");
2932
+ const returnTo = resolveLoginReturnTo(body.returnTo, options);
2933
+ if (proofVerifier === void 0 && capturedProof.header.count !== 0) {
2934
+ throw new OidcVaultHttpError(
2935
+ 401,
2936
+ "OIDC_VAULT_INVALID_DPOP_PROOF",
2937
+ "DPoP login initiation is not enabled.",
2938
+ "DPoP proof validation failed."
2939
+ );
1619
2940
  }
1620
- const frontendDestination = resolveFrontendRedirectUri2(transaction, options);
2941
+ const verifiedBinding = await proofVerifier?.initiate(capturedProof);
2942
+ const binding = snapshotDpopBinding(verifiedBinding);
1621
2943
  const metadata = await resolveProviderMetadata(config, options);
1622
- const tokenResponse = await requestToken(
1623
- metadata,
1624
- {
1625
- grant_type: "authorization_code",
1626
- code,
1627
- code_verifier: transaction.pkceVerifier,
1628
- redirect_uri: getCallbackUri(backendOrigin, basePath)
1629
- },
1630
- options
1631
- );
1632
- validateCallbackTokenResponse(tokenResponse);
1633
- await callHook("callback", options.hooks?.onCallbackTokens, req, res, void 0, {
1634
- hasAccessToken: Boolean(tokenResponse.access_token),
1635
- hasRefreshToken: true,
1636
- hasIdToken: true
1637
- });
1638
- const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce, options);
1639
- const subject = getRequiredString(claims.sub, "OIDC id_token is missing sub.", "OIDC_VAULT_INVALID_ID_TOKEN", 502);
1640
- const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
1641
- if (userInfo !== void 0) {
1642
- assertUserInfoSubject(userInfo, subject);
1643
- }
1644
- if (userInfo !== void 0) {
1645
- await callHook("callback", options.hooks?.onUserInfo, req, res, void 0, { subject });
1646
- }
2944
+ await callHook("login", options.hooks?.onLoginStart, req, res, void 0, { provider: { ...metadata } });
1647
2945
  const now = getNow(options);
1648
- const sessionExpiresAt = options.sessionTtlMs === void 0 ? void 0 : computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
1649
- const exchangeExpiresAt = computeExpiresAt(
1650
- now,
1651
- options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS,
1652
- "exchangeCodeTtlMs"
1653
- );
1654
- const sessionId = createOpaqueId("sess");
1655
- const session = {
1656
- sessionId,
1657
- logicalSessionId: sessionId,
1658
- subject,
1659
- providerSessionId: typeof claims.sid === "string" ? claims.sid : void 0,
1660
- provider: {
1661
- issuer: metadata.issuer ?? (typeof claims.iss === "string" ? claims.iss : void 0),
1662
- clientId: metadata.clientId
1663
- },
1664
- refreshToken: tokenResponse.refresh_token,
1665
- idToken: tokenResponse.id_token,
1666
- accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : void 0,
1667
- scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
2946
+ const browserBinding = createTransactionBrowserBinding();
2947
+ const pkceVerifier = createPkceVerifier();
2948
+ const transaction = {
2949
+ state: createOpaqueId("state"),
2950
+ nonce: createOpaqueId("nonce"),
2951
+ pkceVerifier,
2952
+ codeChallenge: createPkceChallenge(pkceVerifier),
2953
+ returnTo,
2954
+ ...binding === void 0 ? {} : { deviceBinding: binding },
2955
+ browserBindingHash: browserBinding.browserBindingHash,
1668
2956
  createdAt: now,
1669
- updatedAt: now,
1670
- ...sessionExpiresAt === void 0 ? {} : { expiresAt: sessionExpiresAt },
1671
- user: mergeUserProfile(subject, claims, userInfo),
1672
- metadata: {
1673
- tokenType: tokenResponse.token_type
1674
- }
2957
+ expiresAt: computeExpiresAt(
2958
+ now,
2959
+ options.authorizationTransactionTtlMs ?? DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS,
2960
+ "authorizationTransactionTtlMs"
2961
+ ),
2962
+ metadata: Object.freeze({
2963
+ ...createTransactionProviderMetadata(config),
2964
+ ...fingerprintRecognition === void 0 ? {} : { [FINGERPRINT_RECOGNITION_METADATA_KEY]: fingerprintRecognition }
2965
+ })
1675
2966
  };
1676
- await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
1677
- if (sessionExpiresAt !== void 0) {
1678
- session.expiresAt = isUsableEpochMs(session.expiresAt) ? Math.min(session.expiresAt, sessionExpiresAt) : sessionExpiresAt;
2967
+ const authorizationUrl = buildAuthorizationUrl(metadata, transaction, getCallbackUri(backendOrigin, basePath));
2968
+ const { state, expiresAt } = transaction;
2969
+ await options.storeProvider.createAuthorizationTransaction(transaction);
2970
+ await callHook("login", options.hooks?.onAuthorizationUrl, req, res, void 0, { authorizationUrl, state });
2971
+ setTransactionCookie(res, options.transactionCookie, browserBinding.secret, expiresAt, getNow(options));
2972
+ applyCredentialResponseCachePolicy(res);
2973
+ res.status(200).json({ authorizationUrl });
2974
+ });
2975
+ var createCallbackHandler = (options, config, backendOrigin, basePath) => createAsyncHandler("callback", options, async (req, res) => {
2976
+ const providerError = isString(req.query.error) ? req.query.error : void 0;
2977
+ const code = providerError === void 0 ? getRequiredString(
2978
+ req.query.code,
2979
+ "OIDC callback is missing the authorization code.",
2980
+ "OIDC_VAULT_MISSING_CODE"
2981
+ ) : void 0;
2982
+ const state = getRequiredString(req.query.state, "OIDC callback is missing state.", "OIDC_VAULT_MISSING_STATE");
2983
+ const cookieHeader = req.headers.cookie;
2984
+ const store = options.storeProvider;
2985
+ const preflight = typeof store.getAuthorizationTransaction === "function" ? await store.getAuthorizationTransaction(state) : await store.consumeAuthorizationTransaction(state);
2986
+ if (!preflight) throw invalidAuthorizationState();
2987
+ const transaction = snapshotAuthorizationTransaction(preflight, state, getNow(options));
2988
+ assertAuthorizationTransactionIdentity(transaction, config);
2989
+ assertDeviceBindingPolicy(options.deviceBinding, transaction.deviceBinding);
2990
+ const cookie = options.transactionCookie;
2991
+ const cookieSecret = authenticateTransactionCookie(cookieHeader, cookie, transaction.match);
2992
+ const frontendDestination = providerError === void 0 ? resolveFrontendRedirectUri2(transaction, options) : void 0;
2993
+ if (typeof store.getAuthorizationTransaction === "function") {
2994
+ let consumed;
2995
+ if (typeof store.consumeAuthorizationTransactionIfMatches === "function") {
2996
+ consumed = await store.consumeAuthorizationTransactionIfMatches({ state, match: transaction.match });
2997
+ } else {
2998
+ if (transaction.browserBindingHash !== void 0) {
2999
+ throw new TypeError("Guarded callbacks require storeProvider.consumeAuthorizationTransactionIfMatches.");
3000
+ }
3001
+ consumed = await store.consumeAuthorizationTransaction(state);
3002
+ }
3003
+ if (!consumed) throw invalidAuthorizationState();
3004
+ const returned = snapshotAuthorizationTransaction(consumed, state, getNow(options));
3005
+ assertAuthorizationTransactionIdentity(returned, config);
3006
+ assertDeviceBindingPolicy(options.deviceBinding, returned.deviceBinding);
3007
+ assertAuthorizationTransactionMatches(returned, transaction);
1679
3008
  }
1680
- const createdSession = await options.storeProvider.createSession(session);
1681
- const exchangeCode = createOpaqueId("code");
1682
3009
  try {
1683
- await options.storeProvider.createExchangeCode({
1684
- code: exchangeCode,
1685
- sessionId: createdSession.sessionId,
1686
- returnTo: transaction.returnTo,
1687
- createdAt: now,
1688
- expiresAt: exchangeExpiresAt
3010
+ if (providerError !== void 0) {
3011
+ throw new OidcVaultHttpError(400, "OIDC_VAULT_CALLBACK_ERROR", providerError, "OIDC callback failed.");
3012
+ }
3013
+ const metadata = await resolveProviderMetadata(config, options);
3014
+ const tokenResponse = await requestToken(
3015
+ metadata,
3016
+ {
3017
+ grant_type: "authorization_code",
3018
+ code,
3019
+ code_verifier: transaction.pkceVerifier,
3020
+ redirect_uri: getCallbackUri(backendOrigin, basePath)
3021
+ },
3022
+ options
3023
+ );
3024
+ validateCallbackTokenResponse(tokenResponse);
3025
+ await callHook("callback", options.hooks?.onCallbackTokens, req, res, void 0, {
3026
+ hasAccessToken: Boolean(tokenResponse.access_token),
3027
+ hasRefreshToken: true,
3028
+ hasIdToken: true
1689
3029
  });
1690
- } catch (error) {
1691
- await options.storeProvider.deleteSessionsByLogicalSessionId({
1692
- logicalSessionId: createdSession.logicalSessionId ?? createdSession.sessionId
3030
+ const claims = await verifyIdToken(metadata, tokenResponse.id_token, transaction.nonce, options);
3031
+ const subject = getRequiredString(
3032
+ claims.sub,
3033
+ "OIDC id_token is missing sub.",
3034
+ "OIDC_VAULT_INVALID_ID_TOKEN",
3035
+ 502
3036
+ );
3037
+ const userInfo = shouldFetchUserInfo(options, metadata) && isString(tokenResponse.access_token) ? await fetchUserInfo(metadata, tokenResponse.access_token, options) : void 0;
3038
+ if (userInfo !== void 0) {
3039
+ assertUserInfoSubject(userInfo, subject);
3040
+ await callHook("callback", options.hooks?.onUserInfo, req, res, void 0, { subject });
3041
+ }
3042
+ const now = getNow(options);
3043
+ const sessionExpiresAt = options.sessionTtlMs === void 0 ? void 0 : computeExpiresAt(now, options.sessionTtlMs, "sessionTtlMs");
3044
+ const exchangeExpiresAt = computeExpiresAt(
3045
+ now,
3046
+ options.exchangeCodeTtlMs ?? DEFAULT_EXCHANGE_CODE_TTL_MS,
3047
+ "exchangeCodeTtlMs"
3048
+ );
3049
+ const sessionId = createOpaqueId("sess");
3050
+ const provider = Object.freeze({
3051
+ issuer: metadata.issuer ?? (typeof claims.iss === "string" ? claims.iss : void 0),
3052
+ clientId: metadata.clientId
1693
3053
  });
3054
+ const sessionBinding = transaction.deviceBinding;
3055
+ const recognition = transaction.fingerprintRecognition;
3056
+ const session = {
3057
+ sessionId,
3058
+ logicalSessionId: sessionId,
3059
+ subject,
3060
+ providerSessionId: typeof claims.sid === "string" ? claims.sid : void 0,
3061
+ provider: { ...provider },
3062
+ ...sessionBinding === void 0 ? {} : { deviceBinding: { ...sessionBinding } },
3063
+ refreshToken: tokenResponse.refresh_token,
3064
+ idToken: tokenResponse.id_token,
3065
+ accessToken: isString(tokenResponse.access_token) ? tokenResponse.access_token : void 0,
3066
+ scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : metadata.scopes,
3067
+ createdAt: now,
3068
+ updatedAt: now,
3069
+ ...sessionExpiresAt === void 0 ? {} : { expiresAt: sessionExpiresAt },
3070
+ user: copyVaultUserProfile(mergeUserProfile(subject, claims, userInfo)),
3071
+ metadata: withFingerprintRecognitionMetadata({ tokenType: tokenResponse.token_type }, recognition)
3072
+ };
3073
+ await callHook("callback", options.hooks?.onBeforeSessionCreate, req, res, session, { subject });
3074
+ if (sessionExpiresAt !== void 0) {
3075
+ session.expiresAt = isUsableEpochMs(session.expiresAt) ? Math.min(session.expiresAt, sessionExpiresAt) : sessionExpiresAt;
3076
+ }
3077
+ const guarded = transaction.browserBindingHash !== void 0;
3078
+ const restoredBinding = guarded ? withSessionDeviceBinding(
3079
+ {
3080
+ ...copySessionApplicationFields(session),
3081
+ sessionId,
3082
+ logicalSessionId: sessionId,
3083
+ provider: { ...provider }
3084
+ },
3085
+ sessionBinding
3086
+ ) : withSessionDeviceBinding(session, sessionBinding);
3087
+ const restored = {
3088
+ ...restoredBinding,
3089
+ user: copyVaultUserProfile(restoredBinding.user),
3090
+ metadata: withFingerprintRecognitionMetadata(restoredBinding.metadata, recognition)
3091
+ };
3092
+ const createdSession = await store.createSession(restored);
3093
+ const createdSessionId = guarded ? sessionId : createdSession.sessionId;
3094
+ const logicalSessionId = guarded ? sessionId : createdSession.logicalSessionId ?? createdSessionId;
3095
+ const exchangeCode = createOpaqueId("code");
3096
+ try {
3097
+ const returnedBinding = snapshotDpopBinding(createdSession.deviceBinding);
3098
+ if (returnedBinding?.jkt !== sessionBinding?.jkt) throw invalidAuthorizationState();
3099
+ if (!fingerprintRecognitionMatches(snapshotFingerprintRecognition(createdSession.metadata), recognition))
3100
+ throw invalidAuthorizationState();
3101
+ if (guarded && (createdSession.sessionId !== sessionId || createdSession.logicalSessionId !== sessionId || createdSession.provider?.issuer !== provider.issuer || createdSession.provider?.clientId !== provider.clientId)) {
3102
+ throw invalidAuthorizationState();
3103
+ }
3104
+ await store.createExchangeCode({
3105
+ code: exchangeCode,
3106
+ sessionId: createdSessionId,
3107
+ returnTo: transaction.returnTo,
3108
+ ...sessionBinding === void 0 ? {} : { deviceBinding: sessionBinding },
3109
+ ...transaction.browserBindingHash === void 0 ? {} : { browserBindingHash: transaction.browserBindingHash },
3110
+ createdAt: now,
3111
+ expiresAt: exchangeExpiresAt
3112
+ });
3113
+ } catch (error) {
3114
+ await store.deleteSessionsByLogicalSessionId({ logicalSessionId });
3115
+ throw error;
3116
+ }
3117
+ await callPostCommitHook(
3118
+ "callback",
3119
+ options,
3120
+ options.hooks?.onSessionCreated,
3121
+ req,
3122
+ res,
3123
+ withSessionDeviceBinding(copyVaultSession(createdSession), sessionBinding),
3124
+ { subject }
3125
+ );
3126
+ if (cookieSecret !== void 0) {
3127
+ setTransactionCookie(res, cookie, cookieSecret, exchangeExpiresAt, getNow(options));
3128
+ }
3129
+ applyCredentialResponseCachePolicy(res);
3130
+ res.redirect(302, appendCodeToRedirectUri(frontendDestination, exchangeCode));
3131
+ } catch (error) {
3132
+ if (cookieSecret !== void 0) clearTransactionCookie(res, cookie);
1694
3133
  throw error;
1695
3134
  }
1696
- await callPostCommitHook("callback", options, options.hooks?.onSessionCreated, req, res, createdSession, {
1697
- subject
1698
- });
1699
- res.redirect(302, appendCodeToRedirectUri(frontendDestination, exchangeCode));
1700
3135
  });
1701
- var createExchangeHandler = (options, config) => createAsyncHandler("exchange", options, async (req, res) => {
1702
- const body = getBody(req);
1703
- const code = getRequiredString(body.code, "Exchange request is missing code.", "OIDC_VAULT_MISSING_EXCHANGE_CODE");
1704
- const exchangeRecord = await options.storeProvider.consumeExchangeCode(code);
1705
- if (!exchangeRecord) {
1706
- throw new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
3136
+ var assertLoginInitiationRequest = (req, options, trustedOrigins) => {
3137
+ assertTrustedOrigin(req, options, trustedOrigins, "login");
3138
+ if (!req.is("application/json")) {
3139
+ throw new OidcVaultHttpError(
3140
+ 415,
3141
+ "OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE",
3142
+ "Login initiation requires a JSON request body."
3143
+ );
1707
3144
  }
1708
- const session = await options.storeProvider.getSession(exchangeRecord.sessionId);
1709
- if (!session) {
1710
- throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
3145
+ };
3146
+ var copySessionApplicationFields = (session) => {
3147
+ const result = {};
3148
+ for (const key of Object.keys(session)) {
3149
+ if (["deviceBinding", "browserBindingHash", "sessionId", "logicalSessionId", "provider"].includes(key)) continue;
3150
+ Object.defineProperty(result, key, {
3151
+ value: Reflect.get(session, key),
3152
+ enumerable: true,
3153
+ configurable: true,
3154
+ writable: true
3155
+ });
1711
3156
  }
1712
- assertSessionIdentity(session, config);
1713
- let issuedToken;
3157
+ return result;
3158
+ };
3159
+ var createExchangeHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("exchange", options, async (req, res) => {
3160
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
3161
+ const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
3162
+ const assertExchangeOrigin = captureTrustedOriginGuard(req, options, trustedOrigins, "exchange");
3163
+ const cookieHeader = req.headers.cookie;
3164
+ const body = getBody(req);
3165
+ const code = getRequiredString(body.code, "Exchange request is missing code.", "OIDC_VAULT_MISSING_EXCHANGE_CODE");
3166
+ const store = options.storeProvider;
3167
+ const canPreflight = typeof store.getExchangeCode === "function";
3168
+ if ((proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) && (!canPreflight || typeof store.consumeExchangeCodeIfMatches !== "function")) {
3169
+ throw new TypeError("Device-bound exchange requires guarded store reads and atomic consumption.");
3170
+ }
3171
+ const rawCode = canPreflight ? await store.getExchangeCode(code) : await store.consumeExchangeCode(code);
3172
+ if (!rawCode) throw invalidExchangeCode();
3173
+ const record = snapshotVaultExchangeCode(rawCode, code, getNow(options));
3174
+ const guarded = record.browserBindingHash !== void 0;
3175
+ if (guarded) assertExchangeOrigin();
3176
+ assertDeviceBindingPolicy(options.deviceBinding, record.deviceBinding);
3177
+ const rawSession = await store.getSession(record.sessionId);
3178
+ if (!rawSession) throw invalidVaultSession();
3179
+ const session = snapshotVaultSession(rawSession, record.sessionId, getNow(options));
3180
+ assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
3181
+ assertVaultExchangeSessionBinding(record, session);
3182
+ const protectedExchange = proofVerifier !== void 0 || options.fingerprintRecognition !== void 0 || guarded;
3183
+ if (protectedExchange && typeof store.consumeExchangeCodeIfMatches !== "function") {
3184
+ throw new TypeError("Guarded exchange requires storeProvider.consumeExchangeCodeIfMatches.");
3185
+ }
3186
+ if (protectedExchange) assertVaultSessionIdentity(session, config);
3187
+ const cookieSecret = authenticateTransactionCookie(cookieHeader, options.transactionCookie, record.match);
3188
+ const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(session.metadata);
3189
+ assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
3190
+ const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, session.deviceBinding);
3191
+ if (canPreflight) {
3192
+ const consumed = protectedExchange ? await store.consumeExchangeCodeIfMatches({
3193
+ code,
3194
+ expectedSessionId: record.sessionId,
3195
+ match: record.match
3196
+ }) : await store.consumeExchangeCode(code);
3197
+ if (!consumed) throw invalidExchangeCode();
3198
+ const returned = snapshotVaultExchangeCode(consumed, code, getNow(options));
3199
+ assertVaultExchangeCodeMatches(returned, record);
3200
+ const returnedSession = await store.getSession(record.sessionId);
3201
+ if (!returnedSession) throw invalidVaultSession();
3202
+ const checkedSession = snapshotVaultSession(returnedSession, record.sessionId, getNow(options));
3203
+ assertVaultSessionIdentity(checkedSession, config);
3204
+ assertVaultExchangeSessionBinding(returned, checkedSession);
3205
+ assertVaultSessionMatches(checkedSession, session);
3206
+ if (options.fingerprintRecognition !== void 0 && !fingerprintRecognitionMatches(snapshotFingerprintRecognition(checkedSession.metadata), recognition))
3207
+ throw invalidVaultSession();
3208
+ }
3209
+ assertVaultSessionIdentity(session, config);
1714
3210
  try {
1715
- issuedToken = await withIssuedToken(req, res, options, session);
3211
+ const issuedToken = await withIssuedToken(req, res, options, session, verifiedBinding);
3212
+ if (usesCookieTransport(options)) setSessionCookie(res, options, session.sessionId);
3213
+ if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
3214
+ applyCredentialResponseCachePolicy(res);
3215
+ res.status(200).json(createExchangeResponse(options, session, issuedToken));
1716
3216
  } catch (error) {
1717
- await options.storeProvider.deleteSessionsByLogicalSessionId({
1718
- logicalSessionId: session.logicalSessionId ?? session.sessionId
1719
- });
1720
- if (usesCookieTransport(options)) {
1721
- clearSessionCookie(res, options);
1722
- }
3217
+ if (cookieSecret !== void 0) clearTransactionCookie(res, options.transactionCookie);
1723
3218
  throw error;
1724
3219
  }
1725
- if (usesCookieTransport(options)) {
1726
- setSessionCookie(res, options, session.sessionId);
1727
- }
1728
- const response = createExchangeResponse(options, session, issuedToken);
1729
- res.status(200).json(response);
1730
3220
  });
1731
- var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandler("refresh", options, async (req, res) => {
3221
+ var createRefreshHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("refresh", options, async (req, res) => {
3222
+ const capturedRecognition = captureFingerprintRecognition(req, options.fingerprintRecognition);
1732
3223
  assertTrustedOrigin(req, options, trustedOrigins, "refresh");
3224
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
1733
3225
  const sessionId = getSessionIdFromRequest(req, options, "refresh");
1734
- const currentSession = await options.storeProvider.getSession(sessionId);
1735
- if (!currentSession) {
3226
+ const currentRecord = await options.storeProvider.getSession(sessionId);
3227
+ if (!currentRecord) {
1736
3228
  if (usesCookieTransport(options)) {
1737
3229
  clearSessionCookie(res, options);
1738
3230
  }
1739
3231
  throw new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1740
3232
  }
1741
- assertSessionIdentity(currentSession, config);
3233
+ const currentSession = snapshotVaultSession(currentRecord, sessionId, getNow(options));
3234
+ assertVaultSessionIdentity(currentSession, config);
3235
+ assertDeviceBindingPolicy(options.deviceBinding, currentSession.deviceBinding);
3236
+ const recognition = options.fingerprintRecognition === void 0 ? void 0 : snapshotFingerprintRecognition(currentSession.metadata);
3237
+ assertFingerprintRecognition(options.fingerprintRecognition, recognition, capturedRecognition);
3238
+ const verifiedBinding = proofVerifier === void 0 ? void 0 : await proofVerifier.verify(capturedProof, currentSession.deviceBinding);
1742
3239
  const metadata = await resolveProviderMetadata(config, options);
3240
+ if (proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) {
3241
+ const latest = await options.storeProvider.getSession(sessionId);
3242
+ if (!latest) throw invalidVaultSession();
3243
+ const latestSession = snapshotVaultSession(latest, sessionId, getNow(options));
3244
+ assertVaultSessionMatches(latestSession, currentSession);
3245
+ if (options.fingerprintRecognition !== void 0 && !fingerprintRecognitionMatches(snapshotFingerprintRecognition(latestSession.metadata), recognition))
3246
+ throw invalidVaultSession();
3247
+ }
1743
3248
  const tokenResponse = await requestToken(
1744
3249
  metadata,
1745
3250
  {
@@ -1763,7 +3268,7 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1763
3268
  const now = getNow(options);
1764
3269
  const logicalSessionId = currentSession.logicalSessionId ?? currentSession.sessionId;
1765
3270
  const nextSession = {
1766
- ...currentSession,
3271
+ ...copyVaultSession(currentSession),
1767
3272
  sessionId: createOpaqueId("sess"),
1768
3273
  logicalSessionId,
1769
3274
  subject,
@@ -1774,13 +3279,14 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1774
3279
  scope: typeof tokenResponse.scope === "string" ? tokenResponse.scope : currentSession.scope,
1775
3280
  expiresAt: currentSession.expiresAt,
1776
3281
  updatedAt: now,
1777
- user: composeRefreshedUserProfile(subject, currentSession.user, freshClaims, userInfo)
3282
+ user: copyVaultUserProfile(composeRefreshedUserProfile(subject, currentSession.user, freshClaims, userInfo))
1778
3283
  };
1779
- let rotatedSession;
3284
+ const expectedSession = snapshotVaultSession(nextSession, nextSession.sessionId, getNow(options));
3285
+ let rotatedRecord;
1780
3286
  try {
1781
- rotatedSession = await options.storeProvider.rotateSession({
3287
+ rotatedRecord = await options.storeProvider.rotateSession({
1782
3288
  sessionId: currentSession.sessionId,
1783
- nextSession
3289
+ nextSession: copyVaultSession(expectedSession)
1784
3290
  });
1785
3291
  } catch (error) {
1786
3292
  if (isStoreConflictError(error)) {
@@ -1791,51 +3297,109 @@ var createRefreshHandler = (options, config, trustedOrigins) => createAsyncHandl
1791
3297
  }
1792
3298
  throw error;
1793
3299
  }
1794
- let issuedToken;
1795
3300
  try {
1796
- issuedToken = await withIssuedToken(req, res, options, rotatedSession);
3301
+ const returnedSession = snapshotVaultSession(rotatedRecord, expectedSession.sessionId, getNow(options));
3302
+ assertVaultSessionIdentity(returnedSession, config);
3303
+ assertVaultSessionMatches(returnedSession, expectedSession);
3304
+ if (options.fingerprintRecognition !== void 0 && !fingerprintRecognitionMatches(snapshotFingerprintRecognition(returnedSession.metadata), recognition))
3305
+ throw invalidVaultSession();
1797
3306
  } catch (error) {
1798
- await options.storeProvider.deleteSessionsByLogicalSessionId({
1799
- logicalSessionId: rotatedSession.logicalSessionId ?? rotatedSession.sessionId
1800
- });
1801
- if (usesCookieTransport(options)) {
1802
- clearSessionCookie(res, options);
1803
- }
3307
+ await options.storeProvider.deleteSessionsByLogicalSessionId({ logicalSessionId });
3308
+ if (usesCookieTransport(options)) clearSessionCookie(res, options);
1804
3309
  throw error;
1805
3310
  }
1806
- await callPostCommitHook("refresh", options, options.hooks?.onSessionRefreshed, req, res, rotatedSession, {
1807
- previousSessionId: currentSession.sessionId
1808
- });
3311
+ const rotatedSession = expectedSession;
3312
+ const issuedToken = await withIssuedToken(req, res, options, rotatedSession, verifiedBinding);
3313
+ await callPostCommitHook(
3314
+ "refresh",
3315
+ options,
3316
+ options.hooks?.onSessionRefreshed,
3317
+ req,
3318
+ res,
3319
+ copyVaultSession(rotatedSession),
3320
+ {
3321
+ previousSessionId: currentSession.sessionId
3322
+ }
3323
+ );
1809
3324
  if (usesCookieTransport(options)) {
1810
3325
  setSessionCookie(res, options, rotatedSession.sessionId);
1811
3326
  }
1812
3327
  const response = createExchangeResponse(options, rotatedSession, issuedToken);
3328
+ applyCredentialResponseCachePolicy(res);
1813
3329
  res.status(200).json(response);
1814
3330
  });
1815
- var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandler("logout", options, async (req, res) => {
3331
+ var createLogoutHandler = (options, config, trustedOrigins, proofVerifier) => createAsyncHandler("logout", options, async (req, res) => {
1816
3332
  assertTrustedOrigin(req, options, trustedOrigins, "logout");
3333
+ const capturedProof = proofVerifier === void 0 ? void 0 : captureVaultRouteProof(req);
1817
3334
  const body = getBody(req);
1818
3335
  const sessionId = getSessionIdFromRequest(req, options, "logout");
1819
3336
  const redirect = body.redirect === true;
1820
- const session = await options.storeProvider.getSession(sessionId);
1821
- if (!session) {
1822
- await options.storeProvider.deleteSession(sessionId);
1823
- if (usesCookieTransport(options)) {
1824
- clearSessionCookie(res, options);
1825
- }
3337
+ const store = options.storeProvider;
3338
+ const hasContext = typeof store.getSessionRevocationContext === "function";
3339
+ if (proofVerifier !== void 0 && !hasContext) {
3340
+ throw new TypeError("Device-bound logout requires storeProvider.getSessionRevocationContext.");
3341
+ }
3342
+ const rawContext = hasContext ? await store.getSessionRevocationContext(sessionId) : void 0;
3343
+ const context = rawContext ? snapshotVaultRevocationContext(rawContext) : void 0;
3344
+ const rawSession = await store.getSession(sessionId);
3345
+ const session = rawSession ? snapshotVaultSession(rawSession, sessionId, getNow(options)) : void 0;
3346
+ const finishLocalLogout = () => {
3347
+ if (usesCookieTransport(options)) clearSessionCookie(res, options);
3348
+ applyCredentialResponseCachePolicy(res);
1826
3349
  res.status(200).json({ loggedOut: true });
3350
+ };
3351
+ if (hasContext && context === void 0) {
3352
+ if (session !== void 0) {
3353
+ assertDeviceBindingPolicy(options.deviceBinding, session.deviceBinding);
3354
+ throw invalidVaultSession();
3355
+ }
3356
+ finishLocalLogout();
1827
3357
  return;
1828
3358
  }
1829
- assertSessionIdentity(session, config);
1830
- await callHook("logout", options.hooks?.onBeforeLogout, req, res, session, void 0);
1831
- await options.storeProvider.deleteSessionsByLogicalSessionId({
1832
- logicalSessionId: session.logicalSessionId ?? session.sessionId
1833
- });
3359
+ const authority = context ?? (session === void 0 ? void 0 : snapshotVaultRevocationContext({
3360
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
3361
+ provider: session.provider,
3362
+ deviceBinding: session.deviceBinding
3363
+ }));
3364
+ if (authority === void 0) {
3365
+ await store.deleteSession(sessionId);
3366
+ finishLocalLogout();
3367
+ return;
3368
+ }
3369
+ assertVaultSessionIdentity(authority, config);
3370
+ assertDeviceBindingPolicy(options.deviceBinding, authority.deviceBinding);
3371
+ if (session !== void 0)
3372
+ assertVaultRevocationContextMatches(
3373
+ snapshotVaultRevocationContext({
3374
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
3375
+ provider: session.provider,
3376
+ deviceBinding: session.deviceBinding
3377
+ }),
3378
+ authority
3379
+ );
3380
+ if (proofVerifier !== void 0) await proofVerifier.verify(capturedProof, authority.deviceBinding);
3381
+ if (hasContext) {
3382
+ const latest = await store.getSessionRevocationContext(sessionId);
3383
+ if (!latest) {
3384
+ finishLocalLogout();
3385
+ return;
3386
+ }
3387
+ assertVaultRevocationContextMatches(snapshotVaultRevocationContext(latest), authority);
3388
+ }
3389
+ if (session !== void 0)
3390
+ await callHook("logout", options.hooks?.onBeforeLogout, req, res, copyVaultSession(session));
3391
+ await store.deleteSessionsByLogicalSessionId({ logicalSessionId: authority.logicalSessionId });
1834
3392
  if (usesCookieTransport(options)) {
1835
3393
  clearSessionCookie(res, options);
1836
3394
  }
3395
+ if (session === void 0) {
3396
+ applyCredentialResponseCachePolicy(res);
3397
+ res.status(200).json({ loggedOut: true });
3398
+ return;
3399
+ }
1837
3400
  if (!redirect) {
1838
- await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, session);
3401
+ await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3402
+ applyCredentialResponseCachePolicy(res);
1839
3403
  res.status(200).json({ loggedOut: true });
1840
3404
  return;
1841
3405
  }
@@ -1846,13 +3410,14 @@ var createLogoutHandler = (options, config, trustedOrigins) => createAsyncHandle
1846
3410
  } catch (error) {
1847
3411
  try {
1848
3412
  await options.hooks?.onError?.({
1849
- ...createHookContext("logout", req, res, session),
3413
+ ...createHookContext("logout", req, res, copyVaultSession(session)),
1850
3414
  error
1851
3415
  });
1852
3416
  } catch {
1853
3417
  }
1854
3418
  }
1855
- await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, session);
3419
+ await callPostCommitHook("logout", options, options.hooks?.onLogout, req, res, copyVaultSession(session));
3420
+ applyCredentialResponseCachePolicy(res);
1856
3421
  if (redirect && upstreamLogoutUrl) {
1857
3422
  res.redirect(302, upstreamLogoutUrl);
1858
3423
  return;
@@ -1910,11 +3475,18 @@ var createBackchannelLogoutHandler = (options, config) => createAsyncHandler("ba
1910
3475
  });
1911
3476
  });
1912
3477
  function registerRoutes(router, options, config, trustedOrigins, backendOrigin, basePath) {
3478
+ const proofVerifier = createVaultRouteProofVerifier(options, config, backendOrigin, basePath);
1913
3479
  router.get(OIDC_VAULT_ROUTE_PATHS.login, createLoginHandler(options, config, backendOrigin, basePath));
3480
+ if (proofVerifier !== void 0 || options.fingerprintRecognition !== void 0) {
3481
+ router.post(
3482
+ OIDC_VAULT_ROUTE_PATHS.login,
3483
+ createLoginInitiationHandler(options, config, trustedOrigins, backendOrigin, basePath, proofVerifier)
3484
+ );
3485
+ }
1914
3486
  router.get(OIDC_VAULT_ROUTE_PATHS.callback, createCallbackHandler(options, config, backendOrigin, basePath));
1915
- router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options, config));
1916
- router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins));
1917
- router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins));
3487
+ router.post(OIDC_VAULT_ROUTE_PATHS.exchange, createExchangeHandler(options, config, trustedOrigins, proofVerifier));
3488
+ router.post(OIDC_VAULT_ROUTE_PATHS.refresh, createRefreshHandler(options, config, trustedOrigins, proofVerifier));
3489
+ router.post(OIDC_VAULT_ROUTE_PATHS.logout, createLogoutHandler(options, config, trustedOrigins, proofVerifier));
1918
3490
  router.post(OIDC_VAULT_ROUTE_PATHS["backchannel-logout"], createBackchannelLogoutHandler(options, config));
1919
3491
  }
1920
3492
  function createOidcVaultMiddleware(options) {
@@ -1927,6 +3499,15 @@ function createOidcVaultMiddleware(options) {
1927
3499
  applyCredentialResponseCachePolicy(res);
1928
3500
  next();
1929
3501
  });
3502
+ if (resolvedOptions.deviceBinding !== void 0 || resolvedOptions.fingerprintRecognition !== void 0) {
3503
+ baseRouter.post(
3504
+ OIDC_VAULT_ROUTE_PATHS.login,
3505
+ createAsyncHandler("login", resolvedOptions, async (req, res, next) => {
3506
+ assertLoginInitiationRequest(req, resolvedOptions, trustedOrigins);
3507
+ next();
3508
+ })
3509
+ );
3510
+ }
1930
3511
  baseRouter.use(import_express.default.json({ limit: requestBodyLimit }));
1931
3512
  baseRouter.use(
1932
3513
  import_express.default.urlencoded({
@@ -1949,6 +3530,7 @@ function createOidcVaultMiddleware(options) {
1949
3530
  DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT,
1950
3531
  OIDC_VAULT_ROUTE_PATHS,
1951
3532
  OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT,
3533
+ OidcVaultDpopReplayCapacityError,
1952
3534
  OidcVaultStoreConflictError,
1953
3535
  createOidcVaultAccessTokenMiddleware,
1954
3536
  createOidcVaultJwtAccessTokenValidator,