@web-ts-toolkit/express-oidc-vault 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -290,6 +290,26 @@ var parseSelectedCookieValue = (headerValue, selectedName) => {
290
290
 
291
291
  // src/device-binding-policy.ts
292
292
  var import_node_net = require("net");
293
+
294
+ // src/dpop-failure-policy.ts
295
+ var CHECKS = {
296
+ target: { step: 6, option: "ignoreTargetFailure", env: "OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE" },
297
+ freshness: { step: 7, option: "ignoreFreshnessFailure", env: "OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE" },
298
+ replay: { step: 8, option: "ignoreReplayFailure", env: "OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE" }
299
+ };
300
+ var resolveDpopFailureOption = (value, check) => {
301
+ const { option, env } = CHECKS[check];
302
+ if (value === void 0) return process.env[env] === "true";
303
+ if (typeof value !== "boolean") throw new TypeError(`deviceBinding.${option} must be a boolean.`);
304
+ return value;
305
+ };
306
+ var handleDpopCheckFailure = (ignore, check, error) => {
307
+ if (!ignore) throw error;
308
+ const { step, option, env } = CHECKS[check];
309
+ console.warn(`[express-oidc-vault] DPoP step ${step} (${check}) failed; continuing (${option}=true; ${env}).`, error);
310
+ };
311
+
312
+ // src/device-binding-policy.ts
293
313
  var isPlainRecord = (value) => {
294
314
  if (typeof value !== "object" || value === null) return false;
295
315
  const prototype = Object.getPrototypeOf(value);
@@ -306,7 +326,16 @@ var resolveInteger = (value, fallback, min, max, name) => {
306
326
  var resolveDeviceBindingOptions = (value) => {
307
327
  if (value === void 0) return void 0;
308
328
  if (!isPlainRecord(value)) throw new Error("deviceBinding must be a plain options object.");
309
- const { mode, algorithms, proofMaxAgeSeconds, clockSkewSeconds, nonce } = value;
329
+ const {
330
+ mode,
331
+ algorithms,
332
+ proofMaxAgeSeconds,
333
+ clockSkewSeconds,
334
+ nonce,
335
+ ignoreTargetFailure,
336
+ ignoreFreshnessFailure,
337
+ ignoreReplayFailure
338
+ } = value;
310
339
  if (mode !== void 0 && mode !== "optional" && mode !== "required") {
311
340
  throw new Error("deviceBinding.mode must be optional or required.");
312
341
  }
@@ -338,7 +367,10 @@ var resolveDeviceBindingOptions = (value) => {
338
367
  algorithms: Object.freeze([...new Set(allowlist)]),
339
368
  proofMaxAgeSeconds: resolveInteger(proofMaxAgeSeconds, 60, 1, 300, "deviceBinding.proofMaxAgeSeconds"),
340
369
  clockSkewSeconds: resolveInteger(clockSkewSeconds, 5, 0, 30, "deviceBinding.clockSkewSeconds"),
341
- nonce: resolvedNonce
370
+ nonce: resolvedNonce,
371
+ ignoreTargetFailure: resolveDpopFailureOption(ignoreTargetFailure, "target"),
372
+ ignoreFreshnessFailure: resolveDpopFailureOption(ignoreFreshnessFailure, "freshness"),
373
+ ignoreReplayFailure: resolveDpopFailureOption(ignoreReplayFailure, "replay")
342
374
  });
343
375
  };
344
376
  var DEVICE_BINDING_STORE_CAPABILITIES = [
@@ -669,6 +701,7 @@ var publicJwk = (value, alg) => {
669
701
  var verifyDpopProof = async (input) => {
670
702
  const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
671
703
  const algorithms = [...proofOptions.algorithms];
704
+ const { ignoreTargetFailure } = proofOptions;
672
705
  extractDpopProof({ count: 1, value: proof });
673
706
  const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
674
707
  const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
@@ -694,8 +727,13 @@ var verifyDpopProof = async (input) => {
694
727
  if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
695
728
  throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
696
729
  }
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.");
730
+ try {
731
+ const expectedTarget = typeof targetUrl === "function" ? targetUrl() : targetUrl;
732
+ if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== expectedTarget) {
733
+ throw invalidProof("DPoP signed method or normalized public target does not match this request.");
734
+ }
735
+ } catch (error) {
736
+ handleDpopCheckFailure(ignoreTargetFailure, "target", error);
699
737
  }
700
738
  if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
701
739
  throw invalidProof("DPoP signed nonce exceeds its byte bound.");
@@ -1795,6 +1833,35 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
1795
1833
  // src/token-issuance.ts
1796
1834
  var import_jose3 = require("jose");
1797
1835
 
1836
+ // src/access-token-confirmation.ts
1837
+ var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
1838
+ var snapshotJkt = (value, invalid = invalidAccessToken) => {
1839
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1840
+ throw invalid("Access token confirmation must be a plain jkt object.");
1841
+ }
1842
+ const prototype = Object.getPrototypeOf(value);
1843
+ const keys = Reflect.ownKeys(value);
1844
+ if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
1845
+ throw invalid("Access token confirmation contains unsupported fields.");
1846
+ }
1847
+ const { jkt } = value;
1848
+ if (!isCanonicalDpopJkt(jkt)) throw invalid("Access token confirmation is not a canonical SHA-256 thumbprint.");
1849
+ return Object.freeze({ jkt });
1850
+ };
1851
+ var isCompactSignedJwt = (token) => {
1852
+ const parts = token.split(".");
1853
+ return parts.length === 3 && parts.every(
1854
+ (part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
1855
+ );
1856
+ };
1857
+ var readJwtExpirationMs = (exp) => {
1858
+ if (typeof exp !== "number" || !Number.isSafeInteger(exp) || exp < 0) return void 0;
1859
+ const milliseconds = exp * 1e3;
1860
+ return Number.isSafeInteger(milliseconds) ? milliseconds : void 0;
1861
+ };
1862
+ var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
1863
+ var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
1864
+
1798
1865
  // src/vault-session.ts
1799
1866
  var invalidVaultSession = () => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1800
1867
  var invalidExchangeCode = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
@@ -1939,13 +2006,7 @@ var createExchangeResponse = (options, session, issuedToken) => ({
1939
2006
  expiresIn: issuedToken.expiresIn,
1940
2007
  tokenType: issuedToken.tokenType
1941
2008
  });
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) => {
2009
+ var assertIssuedBoundJwt = (accessToken, jkt) => {
1949
2010
  if (!isCompactSignedJwt(accessToken)) {
1950
2011
  throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
1951
2012
  }
@@ -1960,10 +2021,16 @@ var assertIssuedTokenConfirmation = (accessToken, jkt) => {
1960
2021
  if (typeof header.alg !== "string" || header.alg.length === 0 || header.alg === "none" || header.b64 === false) {
1961
2022
  throw new TypeError("tokenIssuer.issue accessToken must use a signed JWT algorithm for bound issuance.");
1962
2023
  }
1963
- const cnf = claims.cnf;
1964
- if (typeof cnf !== "object" || cnf === null || Array.isArray(cnf) || cnf.jkt !== jkt) {
2024
+ const confirmation = snapshotJkt(
2025
+ claims.cnf,
2026
+ (diagnostic) => new TypeError(`tokenIssuer.issue accessToken cnf.jkt is invalid. ${diagnostic}`)
2027
+ );
2028
+ if (confirmation.jkt !== jkt) {
1965
2029
  throw new TypeError("tokenIssuer.issue accessToken cnf.jkt must match the verified device binding.");
1966
2030
  }
2031
+ if (readJwtExpirationMs(claims.exp) === void 0) {
2032
+ throw new TypeError("tokenIssuer.issue accessToken exp must be a nonnegative safe integer with safe milliseconds.");
2033
+ }
1967
2034
  };
1968
2035
  var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
1969
2036
  const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
@@ -1996,7 +2063,10 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
1996
2063
  }
1997
2064
  } else {
1998
2065
  if (tokenType !== "DPoP") throw new TypeError("tokenIssuer.issue tokenType must be DPoP for bound issuance.");
1999
- assertIssuedTokenConfirmation(accessToken, binding.jkt);
2066
+ if (!Number.isSafeInteger(expiresIn * 1e3)) {
2067
+ throw new TypeError("tokenIssuer.issue expiresIn must have safe milliseconds for bound issuance.");
2068
+ }
2069
+ assertIssuedBoundJwt(accessToken, binding.jkt);
2000
2070
  }
2001
2071
  return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
2002
2072
  } catch (error) {
@@ -2008,27 +2078,6 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
2008
2078
 
2009
2079
  // src/token-validation.ts
2010
2080
  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
-
2031
- // src/token-validation.ts
2032
2081
  var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
2033
2082
  var validateBackchannelLogoutTokenTyp = (typ) => {
2034
2083
  if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
@@ -2190,6 +2239,14 @@ function createOidcVaultJwtAccessTokenValidator(options) {
2190
2239
  const result = await (0, import_jose4.jwtVerify)(token, key, { issuer, audience, algorithms });
2191
2240
  const claims = result.payload;
2192
2241
  const confirmation = snapshotVerifiedJwtConfirmation(claims);
2242
+ if (confirmation !== null) {
2243
+ if (!isCompactSignedJwt(token)) {
2244
+ throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
2245
+ }
2246
+ if (readJwtExpirationMs(claims.exp) === void 0) {
2247
+ throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
2248
+ }
2249
+ }
2193
2250
  const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
2194
2251
  const { subject, sessionId, scope, claims: mappedClaims } = mapped;
2195
2252
  return { subject, sessionId, scope, claims: mappedClaims, confirmation };
@@ -2309,8 +2366,8 @@ var createDpopReplayPolicy = (options) => {
2309
2366
  throw invalidProof2("DPoP verifier did not return a verified key binding.");
2310
2367
  }
2311
2368
  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.");
2369
+ if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
2370
+ throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
2314
2371
  }
2315
2372
  const binding = Object.freeze({ type, jkt, alg });
2316
2373
  let checkedNow;
@@ -2318,10 +2375,20 @@ var createDpopReplayPolicy = (options) => {
2318
2375
  checkedNow = now();
2319
2376
  if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
2320
2377
  } catch (error) {
2321
- throw replayUnavailable(error);
2378
+ handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
2379
+ checkedNow = Date.now();
2380
+ if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
2381
+ throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
2382
+ }
2383
+ let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2384
+ if (expiresAt === null) {
2385
+ handleDpopCheckFailure(
2386
+ policy.ignoreFreshnessFailure,
2387
+ "freshness",
2388
+ invalidProof2("DPoP signed iat is outside the bounded replay window.")
2389
+ );
2390
+ expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
2322
2391
  }
2323
- const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2324
- if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
2325
2392
  if (noncePolicy) {
2326
2393
  if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
2327
2394
  return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
@@ -2329,18 +2396,19 @@ var createDpopReplayPolicy = (options) => {
2329
2396
  } else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
2330
2397
  throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
2331
2398
  }
2332
- let reserved;
2399
+ let reservationError;
2333
2400
  try {
2334
- reserved = await replayStore.reserveDpopProof({
2401
+ const reserved = await replayStore.reserveDpopProof({
2335
2402
  replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
2336
2403
  expiresAt
2337
2404
  });
2338
2405
  if (typeof reserved !== "boolean")
2339
2406
  throw new TypeError("DPoP replay store returned a non-boolean admission result.");
2407
+ if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2340
2408
  } catch (error) {
2341
- throw replayUnavailable(error);
2409
+ reservationError = replayUnavailable(error);
2342
2410
  }
2343
- if (!reserved) throw invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2411
+ if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2344
2412
  return Object.freeze({ type: "accepted", binding, expiresAt });
2345
2413
  }
2346
2414
  });
@@ -2403,12 +2471,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2403
2471
  now: options.now
2404
2472
  });
2405
2473
  const admit = async (request, proof, expectedJkt) => {
2406
- const targetUrl = targetFor(request.originalUrl);
2407
2474
  const result = await replay.verifyAndReserve(
2408
2475
  () => verifyDpopProof({
2409
2476
  proof,
2410
2477
  method: request.method,
2411
- targetUrl,
2478
+ targetUrl: () => targetFor(request.originalUrl),
2412
2479
  proofOptions: policy,
2413
2480
  expectedJkt
2414
2481
  })
@@ -2589,12 +2656,11 @@ function createOidcVaultAccessTokenMiddleware(options) {
2589
2656
  if (!proof && confirmation)
2590
2657
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2591
2658
  if (proof) {
2592
- const targetUrl = targetFor(originalUrl);
2593
2659
  const admission = await replay.verifyAndReserve(
2594
2660
  () => verifyDpopProof({
2595
2661
  proof,
2596
2662
  method,
2597
- targetUrl,
2663
+ targetUrl: () => targetFor(originalUrl),
2598
2664
  proofOptions: policy,
2599
2665
  accessToken: token,
2600
2666
  expectedJkt: confirmation?.jkt
package/index.mjs CHANGED
@@ -242,6 +242,26 @@ var parseSelectedCookieValue = (headerValue, selectedName) => {
242
242
 
243
243
  // src/device-binding-policy.ts
244
244
  import { isIP } from "net";
245
+
246
+ // src/dpop-failure-policy.ts
247
+ var CHECKS = {
248
+ target: { step: 6, option: "ignoreTargetFailure", env: "OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE" },
249
+ freshness: { step: 7, option: "ignoreFreshnessFailure", env: "OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE" },
250
+ replay: { step: 8, option: "ignoreReplayFailure", env: "OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE" }
251
+ };
252
+ var resolveDpopFailureOption = (value, check) => {
253
+ const { option, env } = CHECKS[check];
254
+ if (value === void 0) return process.env[env] === "true";
255
+ if (typeof value !== "boolean") throw new TypeError(`deviceBinding.${option} must be a boolean.`);
256
+ return value;
257
+ };
258
+ var handleDpopCheckFailure = (ignore, check, error) => {
259
+ if (!ignore) throw error;
260
+ const { step, option, env } = CHECKS[check];
261
+ console.warn(`[express-oidc-vault] DPoP step ${step} (${check}) failed; continuing (${option}=true; ${env}).`, error);
262
+ };
263
+
264
+ // src/device-binding-policy.ts
245
265
  var isPlainRecord = (value) => {
246
266
  if (typeof value !== "object" || value === null) return false;
247
267
  const prototype = Object.getPrototypeOf(value);
@@ -258,7 +278,16 @@ var resolveInteger = (value, fallback, min, max, name) => {
258
278
  var resolveDeviceBindingOptions = (value) => {
259
279
  if (value === void 0) return void 0;
260
280
  if (!isPlainRecord(value)) throw new Error("deviceBinding must be a plain options object.");
261
- const { mode, algorithms, proofMaxAgeSeconds, clockSkewSeconds, nonce } = value;
281
+ const {
282
+ mode,
283
+ algorithms,
284
+ proofMaxAgeSeconds,
285
+ clockSkewSeconds,
286
+ nonce,
287
+ ignoreTargetFailure,
288
+ ignoreFreshnessFailure,
289
+ ignoreReplayFailure
290
+ } = value;
262
291
  if (mode !== void 0 && mode !== "optional" && mode !== "required") {
263
292
  throw new Error("deviceBinding.mode must be optional or required.");
264
293
  }
@@ -290,7 +319,10 @@ var resolveDeviceBindingOptions = (value) => {
290
319
  algorithms: Object.freeze([...new Set(allowlist)]),
291
320
  proofMaxAgeSeconds: resolveInteger(proofMaxAgeSeconds, 60, 1, 300, "deviceBinding.proofMaxAgeSeconds"),
292
321
  clockSkewSeconds: resolveInteger(clockSkewSeconds, 5, 0, 30, "deviceBinding.clockSkewSeconds"),
293
- nonce: resolvedNonce
322
+ nonce: resolvedNonce,
323
+ ignoreTargetFailure: resolveDpopFailureOption(ignoreTargetFailure, "target"),
324
+ ignoreFreshnessFailure: resolveDpopFailureOption(ignoreFreshnessFailure, "freshness"),
325
+ ignoreReplayFailure: resolveDpopFailureOption(ignoreReplayFailure, "replay")
294
326
  });
295
327
  };
296
328
  var DEVICE_BINDING_STORE_CAPABILITIES = [
@@ -621,6 +653,7 @@ var publicJwk = (value, alg) => {
621
653
  var verifyDpopProof = async (input) => {
622
654
  const { proof, method, targetUrl, proofOptions, expectedJkt, accessToken } = input;
623
655
  const algorithms = [...proofOptions.algorithms];
656
+ const { ignoreTargetFailure } = proofOptions;
624
657
  extractDpopProof({ count: 1, value: proof });
625
658
  const [encodedHeader, encodedPayload, encodedSignature] = proof.split(".");
626
659
  const header = parseObject(decodeBase64url2(encodedHeader, DPOP_PROTECTED_HEADER_MAX_BYTES));
@@ -646,8 +679,13 @@ var verifyDpopProof = async (input) => {
646
679
  if (typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0 || typeof jti !== "string" || jti.length < 1 || jti.length > 128 || /[^\x20-\x7e]/.test(jti)) {
647
680
  throw invalidProof("DPoP signed iat/JTI must be nonnegative integer time and bounded printable ASCII.");
648
681
  }
649
- if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== targetUrl) {
650
- throw invalidProof("DPoP signed method or normalized public target does not match this request.");
682
+ try {
683
+ const expectedTarget = typeof targetUrl === "function" ? targetUrl() : targetUrl;
684
+ if (htm !== method || typeof method !== "string" || method.length === 0 || normalizeDpopProofTarget(htu) !== expectedTarget) {
685
+ throw invalidProof("DPoP signed method or normalized public target does not match this request.");
686
+ }
687
+ } catch (error) {
688
+ handleDpopCheckFailure(ignoreTargetFailure, "target", error);
651
689
  }
652
690
  if (typeof nonce === "string" && (nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
653
691
  throw invalidProof("DPoP signed nonce exceeds its byte bound.");
@@ -1747,6 +1785,35 @@ async function fetchUserInfo(metadata, accessToken, options = {}) {
1747
1785
  // src/token-issuance.ts
1748
1786
  import { decodeJwt, decodeProtectedHeader } from "jose";
1749
1787
 
1788
+ // src/access-token-confirmation.ts
1789
+ var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
1790
+ var snapshotJkt = (value, invalid = invalidAccessToken) => {
1791
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1792
+ throw invalid("Access token confirmation must be a plain jkt object.");
1793
+ }
1794
+ const prototype = Object.getPrototypeOf(value);
1795
+ const keys = Reflect.ownKeys(value);
1796
+ if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
1797
+ throw invalid("Access token confirmation contains unsupported fields.");
1798
+ }
1799
+ const { jkt } = value;
1800
+ if (!isCanonicalDpopJkt(jkt)) throw invalid("Access token confirmation is not a canonical SHA-256 thumbprint.");
1801
+ return Object.freeze({ jkt });
1802
+ };
1803
+ var isCompactSignedJwt = (token) => {
1804
+ const parts = token.split(".");
1805
+ return parts.length === 3 && parts.every(
1806
+ (part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
1807
+ );
1808
+ };
1809
+ var readJwtExpirationMs = (exp) => {
1810
+ if (typeof exp !== "number" || !Number.isSafeInteger(exp) || exp < 0) return void 0;
1811
+ const milliseconds = exp * 1e3;
1812
+ return Number.isSafeInteger(milliseconds) ? milliseconds : void 0;
1813
+ };
1814
+ var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
1815
+ var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
1816
+
1750
1817
  // src/vault-session.ts
1751
1818
  var invalidVaultSession = () => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_SESSION", "Session is missing or expired.");
1752
1819
  var invalidExchangeCode = () => new OidcVaultHttpError(400, "OIDC_VAULT_INVALID_EXCHANGE_CODE", "Exchange code is invalid or expired.");
@@ -1891,13 +1958,7 @@ var createExchangeResponse = (options, session, issuedToken) => ({
1891
1958
  expiresIn: issuedToken.expiresIn,
1892
1959
  tokenType: issuedToken.tokenType
1893
1960
  });
1894
- var isCompactSignedJwt = (token) => {
1895
- const parts = token.split(".");
1896
- return parts.length === 3 && parts.every(
1897
- (part) => /^[A-Za-z0-9_-]+$/.test(part) ? Buffer.from(part, "base64url").toString("base64url") === part : false
1898
- );
1899
- };
1900
- var assertIssuedTokenConfirmation = (accessToken, jkt) => {
1961
+ var assertIssuedBoundJwt = (accessToken, jkt) => {
1901
1962
  if (!isCompactSignedJwt(accessToken)) {
1902
1963
  throw new TypeError("tokenIssuer.issue accessToken must be a compact signed JWT for bound issuance.");
1903
1964
  }
@@ -1912,10 +1973,16 @@ var assertIssuedTokenConfirmation = (accessToken, jkt) => {
1912
1973
  if (typeof header.alg !== "string" || header.alg.length === 0 || header.alg === "none" || header.b64 === false) {
1913
1974
  throw new TypeError("tokenIssuer.issue accessToken must use a signed JWT algorithm for bound issuance.");
1914
1975
  }
1915
- const cnf = claims.cnf;
1916
- if (typeof cnf !== "object" || cnf === null || Array.isArray(cnf) || cnf.jkt !== jkt) {
1976
+ const confirmation = snapshotJkt(
1977
+ claims.cnf,
1978
+ (diagnostic) => new TypeError(`tokenIssuer.issue accessToken cnf.jkt is invalid. ${diagnostic}`)
1979
+ );
1980
+ if (confirmation.jkt !== jkt) {
1917
1981
  throw new TypeError("tokenIssuer.issue accessToken cnf.jkt must match the verified device binding.");
1918
1982
  }
1983
+ if (readJwtExpirationMs(claims.exp) === void 0) {
1984
+ throw new TypeError("tokenIssuer.issue accessToken exp must be a nonnegative safe integer with safe milliseconds.");
1985
+ }
1919
1986
  };
1920
1987
  var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
1921
1988
  const binding = resolveVerifiedDeviceBinding(options.deviceBinding, session.deviceBinding, verifiedBinding);
@@ -1948,7 +2015,10 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
1948
2015
  }
1949
2016
  } else {
1950
2017
  if (tokenType !== "DPoP") throw new TypeError("tokenIssuer.issue tokenType must be DPoP for bound issuance.");
1951
- assertIssuedTokenConfirmation(accessToken, binding.jkt);
2018
+ if (!Number.isSafeInteger(expiresIn * 1e3)) {
2019
+ throw new TypeError("tokenIssuer.issue expiresIn must have safe milliseconds for bound issuance.");
2020
+ }
2021
+ assertIssuedBoundJwt(accessToken, binding.jkt);
1952
2022
  }
1953
2023
  return { accessToken, expiresIn, ...tokenType === void 0 ? {} : { tokenType } };
1954
2024
  } catch (error) {
@@ -1960,27 +2030,6 @@ var withIssuedToken = async (req, res, options, session, verifiedBinding) => {
1960
2030
 
1961
2031
  // src/token-validation.ts
1962
2032
  import { jwtVerify } from "jose";
1963
-
1964
- // src/access-token-confirmation.ts
1965
- var invalidAccessToken = (diagnostic) => new OidcVaultHttpError(401, "OIDC_VAULT_INVALID_ACCESS_TOKEN", diagnostic, "Access token validation failed.");
1966
- var snapshotJkt = (value) => {
1967
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
1968
- throw invalidAccessToken("Access token confirmation must be a plain jkt object.");
1969
- }
1970
- const prototype = Object.getPrototypeOf(value);
1971
- const keys = Reflect.ownKeys(value);
1972
- if (prototype !== Object.prototype && prototype !== null || keys.length !== 1 || keys[0] !== "jkt") {
1973
- throw invalidAccessToken("Access token confirmation contains unsupported fields.");
1974
- }
1975
- const { jkt } = value;
1976
- if (!isCanonicalDpopJkt(jkt))
1977
- throw invalidAccessToken("Access token confirmation is not a canonical SHA-256 thumbprint.");
1978
- return Object.freeze({ jkt });
1979
- };
1980
- var snapshotVerifiedJwtConfirmation = (claims) => Object.hasOwn(claims, "cnf") ? snapshotJkt(claims.cnf) : null;
1981
- var snapshotAccessTokenConfirmation = (value) => value === null ? null : snapshotJkt(value);
1982
-
1983
- // src/token-validation.ts
1984
2033
  var BACKCHANNEL_LOGOUT_EVENT_CLAIM = "http://schemas.openid.net/event/backchannel-logout";
1985
2034
  var validateBackchannelLogoutTokenTyp = (typ) => {
1986
2035
  if (typ !== void 0 && (typeof typ !== "string" || typ.toLowerCase() !== "logout+jwt")) {
@@ -2142,6 +2191,14 @@ function createOidcVaultJwtAccessTokenValidator(options) {
2142
2191
  const result = await jwtVerify(token, key, { issuer, audience, algorithms });
2143
2192
  const claims = result.payload;
2144
2193
  const confirmation = snapshotVerifiedJwtConfirmation(claims);
2194
+ if (confirmation !== null) {
2195
+ if (!isCompactSignedJwt(token)) {
2196
+ throw invalidAccessToken("Bound JWT must use canonical compact signed encoding.");
2197
+ }
2198
+ if (readJwtExpirationMs(claims.exp) === void 0) {
2199
+ throw invalidAccessToken("Bound JWT exp must be a nonnegative safe integer with safe milliseconds.");
2200
+ }
2201
+ }
2145
2202
  const mapped = mapClaims ? mapClaims(claims) : defaultJwtClaimsMapper(claims);
2146
2203
  const { subject, sessionId, scope, claims: mappedClaims } = mapped;
2147
2204
  return { subject, sessionId, scope, claims: mappedClaims, confirmation };
@@ -2261,8 +2318,8 @@ var createDpopReplayPolicy = (options) => {
2261
2318
  throw invalidProof2("DPoP verifier did not return a verified key binding.");
2262
2319
  }
2263
2320
  const { type, jkt, alg } = verifiedBinding;
2264
- if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti)) {
2265
- throw invalidProof2("DPoP verified key/algorithm or signed JTI shape is invalid.");
2321
+ if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
2322
+ throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
2266
2323
  }
2267
2324
  const binding = Object.freeze({ type, jkt, alg });
2268
2325
  let checkedNow;
@@ -2270,10 +2327,20 @@ var createDpopReplayPolicy = (options) => {
2270
2327
  checkedNow = now();
2271
2328
  if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
2272
2329
  } catch (error) {
2273
- throw replayUnavailable(error);
2330
+ handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
2331
+ checkedNow = Date.now();
2332
+ if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
2333
+ throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
2334
+ }
2335
+ let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2336
+ if (expiresAt === null) {
2337
+ handleDpopCheckFailure(
2338
+ policy.ignoreFreshnessFailure,
2339
+ "freshness",
2340
+ invalidProof2("DPoP signed iat is outside the bounded replay window.")
2341
+ );
2342
+ expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
2274
2343
  }
2275
- const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2276
- if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
2277
2344
  if (noncePolicy) {
2278
2345
  if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
2279
2346
  return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
@@ -2281,18 +2348,19 @@ var createDpopReplayPolicy = (options) => {
2281
2348
  } else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
2282
2349
  throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
2283
2350
  }
2284
- let reserved;
2351
+ let reservationError;
2285
2352
  try {
2286
- reserved = await replayStore.reserveDpopProof({
2353
+ const reserved = await replayStore.reserveDpopProof({
2287
2354
  replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
2288
2355
  expiresAt
2289
2356
  });
2290
2357
  if (typeof reserved !== "boolean")
2291
2358
  throw new TypeError("DPoP replay store returned a non-boolean admission result.");
2359
+ if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2292
2360
  } catch (error) {
2293
- throw replayUnavailable(error);
2361
+ reservationError = replayUnavailable(error);
2294
2362
  }
2295
- if (!reserved) throw invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2363
+ if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2296
2364
  return Object.freeze({ type: "accepted", binding, expiresAt });
2297
2365
  }
2298
2366
  });
@@ -2355,12 +2423,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2355
2423
  now: options.now
2356
2424
  });
2357
2425
  const admit = async (request, proof, expectedJkt) => {
2358
- const targetUrl = targetFor(request.originalUrl);
2359
2426
  const result = await replay.verifyAndReserve(
2360
2427
  () => verifyDpopProof({
2361
2428
  proof,
2362
2429
  method: request.method,
2363
- targetUrl,
2430
+ targetUrl: () => targetFor(request.originalUrl),
2364
2431
  proofOptions: policy,
2365
2432
  expectedJkt
2366
2433
  })
@@ -2541,12 +2608,11 @@ function createOidcVaultAccessTokenMiddleware(options) {
2541
2608
  if (!proof && confirmation)
2542
2609
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2543
2610
  if (proof) {
2544
- const targetUrl = targetFor(originalUrl);
2545
2611
  const admission = await replay.verifyAndReserve(
2546
2612
  () => verifyDpopProof({
2547
2613
  proof,
2548
2614
  method,
2549
- targetUrl,
2615
+ targetUrl: () => targetFor(originalUrl),
2550
2616
  proofOptions: policy,
2551
2617
  accessToken: token,
2552
2618
  expectedJkt: confirmation?.jkt
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault",
3
3
  "description": "Express OIDC sessions with body or cookie transport, optional DPoP-bound local JWTs, and pluggable vault stores",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault",
5
- "version": "0.50.0",
5
+ "version": "0.52.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -26,6 +26,16 @@
26
26
  "import": "./index.mjs",
27
27
  "require": "./index.js",
28
28
  "default": "./index.js"
29
+ },
30
+ "./wire": {
31
+ "types": {
32
+ "import": "./wire.d.mts",
33
+ "require": "./wire.d.ts",
34
+ "default": "./wire.d.ts"
35
+ },
36
+ "import": "./wire.mjs",
37
+ "require": "./wire.js",
38
+ "default": "./wire.js"
29
39
  }
30
40
  },
31
41
  "engines": {