@web-ts-toolkit/express-oidc-vault 0.50.0 → 0.51.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/README.md CHANGED
@@ -318,6 +318,47 @@ The shared nonce policy signs versioned, random HMAC-SHA-256 challenges bound to
318
318
 
319
319
  Sessions, authorization transactions, and exchange codes carry optional `deviceBinding: { type: 'dpop', jkt: string }`; transactions/codes also carry optional `browserBindingHash`. Only the canonical RFC 7638 SHA-256 thumbprint is persisted, without a JWK, private key, proof algorithm, or historical mode. Core retains security-owned binding independently of mutable precreate hooks and issuer inputs. All three built-ins implement live preflight reads, atomic exact/null match-and-consume, lineage revocation context, immutable rotation, and shared bounded replay admission; see the provider contracts below. These store primitives do not themselves verify HTTP proofs.
320
320
 
321
+ #### Per-check DPoP failure overrides
322
+
323
+ Steps 6–8 have independent log-and-continue settings. Each check still runs; an enabled setting writes every failure to the server's `console.warn` and continues to the next check. Both vault POSTs and API middleware read these defaults from `process.env` when constructed:
324
+
325
+ | Step | Check | Environment variable | `deviceBinding` option |
326
+ | ---- | ----------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------ |
327
+ | 6 | HTTP method and normalized pinned URL (`htm`/`htu`), including target normalization | `OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE` | `ignoreTargetFailure` |
328
+ | 7 | Signed proof freshness (`iat` age and clock skew) | `OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE` | `ignoreFreshnessFailure` |
329
+ | 8 | Atomic JTI replay admission, including duplicates, capacity, and provider failures | `OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE` | `ignoreReplayFailure` |
330
+
331
+ Only the exact environment value `true` enables an override; unset, `false`, or any other value retains enforcement. Set whichever flags you need before constructing middleware, for example:
332
+
333
+ ```dotenv
334
+ OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE=true
335
+ OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE=false
336
+ OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE=true
337
+ ```
338
+
339
+ Explicit booleans on `OidcVaultDeviceBindingOptions` / `OidcVaultApiDeviceBindingOptions` take precedence over the environment for that policy:
340
+
341
+ ```ts
342
+ import type { OidcVaultDeviceBindingOptions } from '@web-ts-toolkit/express-oidc-vault';
343
+
344
+ const deviceBinding: OidcVaultDeviceBindingOptions = {
345
+ mode: 'required',
346
+ ignoreTargetFailure: true,
347
+ ignoreFreshnessFailure: false,
348
+ ignoreReplayFailure: false,
349
+ };
350
+ ```
351
+
352
+ An ignored freshness failure still reaches nonce verification and replay admission. To give replay admission a live bounded expiry independently of the rejected `iat`, core uses `now + (proofMaxAgeSeconds + clockSkewSeconds) * 1000` (65 seconds with defaults). A failed configured proof clock is logged and falls back to `Date.now`. Successful replay reservations retain their expiry; duplicates never renew them, even when their failure is ignored. Signature, header/JWK, claim-shape, original-key, API `ath`, and nonce checks retain their normal enforcement.
353
+
354
+ Console output identifies the step, option/environment variable, and original error (including replay provider causes), for example:
355
+
356
+ ```text
357
+ [express-oidc-vault] DPoP step 8 (replay) failed; continuing (ignoreReplayFailure=true; OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE).
358
+ ```
359
+
360
+ Environment changes take effect on newly constructed middleware. Ignored failures go directly to the console; `onError` handles failures that stop the request.
361
+
321
362
  ### POST login and browser-authenticated callback
322
363
 
323
364
  Configure the vault with the existing stronger store and an opt-in policy:
@@ -1443,9 +1484,9 @@ DPoP replay is a reservation per accepted proof, including API traffic; it is su
1443
1484
 
1444
1485
  `expiresAt` is a future safe-integer epoch in milliseconds, with at most **360000 ms** remaining. Invalid, fractional, unsafe, nonfinite, expired (`<= now`), or overlong windows return `false` without allocation. Valid expired keys may be reserved again. All instances must use identical namespaces/proof windows and synchronized clocks. The approved proof defaults retain at most 70 seconds of validity; provision capacity for unique proofs/second × retained window plus headroom.
1445
1486
 
1446
- Core derives `replayKey = 'dpop:v1:' + base64url(SHA-256(JSON([effectiveNamespace, jkt, jti])))` and expiry **`(iat + proofMaxAgeSeconds + clockSkewSeconds) * 1000`**. Vault space is `['vault', normalizedBackendOrigin, normalizedBasePath, exactIssuer, exactClientId]`; API space is `['api', normalizedPublicOrigin, replayNamespace]`. API path prefix is a target correction, not replay partitioning. The 360000ms ceiling includes future iat skew (maximum age 300 + twice skew 30); no duplicate extends its reservation.
1487
+ Core derives `replayKey = 'dpop:v1:' + base64url(SHA-256(JSON([effectiveNamespace, jkt, jti])))` and expiry **`(iat + proofMaxAgeSeconds + clockSkewSeconds) * 1000`**. An explicitly ignored freshness failure instead uses the bounded current-time expiry described under [per-check overrides](#per-check-dpop-failure-overrides). Vault space is `['vault', normalizedBackendOrigin, normalizedBasePath, exactIssuer, exactClientId]`; API space is `['api', normalizedPublicOrigin, replayNamespace]`. API path prefix is a target correction, not replay partitioning. The 360000ms ceiling includes future iat skew (maximum age 300 + twice skew 30); no duplicate extends its reservation.
1447
1488
 
1448
- Every provider accepts `dpopReplayMaxEntries?: number`, default **100000**, positive safe integer. Capacity is per memory object, Redis prefix, or paired Mongo replay collections, shared across backend clients. Configure the same limit on every shared client. Duplicates return `false` even when full; new reservations throw root-exported `OidcVaultDpopReplayCapacityError` at capacity. Live reservations are never evicted and store failure never permits acceptance. API middleware and all DPoP-bearing vault POSTs map failures/capacity to sanitized `503 OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE`.
1489
+ Every provider accepts `dpopReplayMaxEntries?: number`, default **100000**, positive safe integer. Capacity is per memory object, Redis prefix, or paired Mongo replay collections, shared across backend clients. Configure the same limit on every shared client. Duplicates return `false` even when full; new reservations throw root-exported `OidcVaultDpopReplayCapacityError` at capacity. Live reservations are never evicted. By default, API middleware and all DPoP-bearing vault POSTs map failures/capacity to sanitized `503 OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE`; `ignoreReplayFailure` logs these failures and continues after attempting admission.
1449
1490
 
1450
1491
  | Provider | Admission/expiry work |
1451
1492
  | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1891,7 +1932,7 @@ Authorization: DPoP <local-access-token>
1891
1932
  DPoP: <fresh-signed-proof-jwt>
1892
1933
  ```
1893
1934
 
1894
- The proof has protected `{ typ: 'dpop+jwt', alg: 'ES256', jwk: <public-P-256-JWK> }` and signed `htm`, absolute `htu` without query/fragment, integer `iat`, fresh `jti`, and API `ath = base64url(SHA-256(ASCII(accessToken)))`. A configured nonce also supplies the signed `nonce`. Signature verification precedes payload/target/key/hash checks. Private/symmetric/remote-key JWKs, critical headers, wrong key/signature/method/URL/hash, and duplicate/stale/replayed proofs fail. Bounds: compact proof 8192 bytes, decoded protected header/JWK 2048 bytes, printable ASCII JTI 1–128 bytes, nonce at most 512 bytes. Raw duplicate Authorization/DPoP fields and comma-joined credentials/proofs are rejected. Generate at least 128 random bits for every JTI and a new proof for every request/retry.
1935
+ The proof has protected `{ typ: 'dpop+jwt', alg: 'ES256', jwk: <public-P-256-JWK> }` and signed `htm`, absolute `htu` without query/fragment, integer `iat`, fresh `jti`, and API `ath = base64url(SHA-256(ASCII(accessToken)))`. A configured nonce also supplies the signed `nonce`. Signature verification precedes payload/target/key/hash checks. By default, private/symmetric/remote-key JWKs, critical headers, wrong key/signature/method/URL/hash, and duplicate/stale/replayed proofs fail. The independent [per-check overrides](#per-check-dpop-failure-overrides) apply to method/URL, freshness, and replay failures. Bounds: compact proof 8192 bytes, decoded protected header/JWK 2048 bytes, printable ASCII JTI 1–128 bytes, nonce at most 512 bytes. Raw duplicate Authorization/DPoP fields and comma-joined credentials/proofs are rejected. Generate at least 128 random bits for every JTI and a new proof for every request/retry.
1895
1936
 
1896
1937
  For optional unbound Bearer requests, an omitted proof stays on the unbound path. If a proof is supplied, it must still pass signature/target/API `ath`/nonce/shared replay and never creates `auth.deviceBinding`.
1897
1938
 
@@ -1899,7 +1940,7 @@ API replay uses the shared `['api', normalizedPublicOrigin, replayNamespace]` pr
1899
1940
 
1900
1941
  #### API errors and nonce retry
1901
1942
 
1902
- For the default ES256 policy (`algs` lists the configured allowlist in order):
1943
+ With default failure enforcement and the ES256 policy (`algs` lists the configured allowlist in order):
1903
1944
 
1904
1945
  | Condition | HTTP / code | `WWW-Authenticate` |
1905
1946
  | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------- |
@@ -1933,7 +1974,7 @@ Vault routes and API middleware return sanitized `{ code, message }`. Invalid pr
1933
1974
  | 400 | `OIDC_VAULT_INVALID_FINGERPRINT` | `Fingerprint signal is invalid.` |
1934
1975
  | 403 | `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` | `Browser recognition changed; sign in again.` |
1935
1976
 
1936
- Source failures retain `403 OIDC_VAULT_UNTRUSTED_ORIGIN` with `Login request origin is not trusted.` / `Exchange request origin is not trusted.` / `Refresh request origin is not trusted.` / `Logout request origin is not trusted.`. An authenticated provider-error callback returns fixed `400 OIDC_VAULT_CALLBACK_ERROR` / `OIDC callback failed.`. Replay storage failure/capacity is a fail-closed 503 with no auth challenge; invalid/missing proof 401s use `invalid_dpop_proof`, and API nonce 401s use `use_dpop_nonce`. Vault nonce 400s carry the one `DPoP-Nonce` header without `WWW-Authenticate`. Every response retains `no-store`; original errors reach `hooks.onError` or the separate API `onError`, with replay provider diagnostics in `error.cause`.
1977
+ Source failures retain `403 OIDC_VAULT_UNTRUSTED_ORIGIN` with `Login request origin is not trusted.` / `Exchange request origin is not trusted.` / `Refresh request origin is not trusted.` / `Logout request origin is not trusted.`. An authenticated provider-error callback returns fixed `400 OIDC_VAULT_CALLBACK_ERROR` / `OIDC callback failed.`. With default enforcement, replay storage failure/capacity is a fail-closed 503 with no auth challenge; invalid/missing proof 401s use `invalid_dpop_proof`, and API nonce 401s use `use_dpop_nonce`. Vault nonce 400s carry the one `DPoP-Nonce` header without `WWW-Authenticate`. Every response retains `no-store`; failures that stop the request reach `hooks.onError` or the separate API `onError`, with replay provider diagnostics in `error.cause`. Independently ignored failures are logged to `console.warn` as described under [per-check overrides](#per-check-dpop-failure-overrides).
1937
1978
 
1938
1979
  Recommended separation:
1939
1980
 
package/index.d.mts CHANGED
@@ -41,6 +41,8 @@ interface OidcVaultDpopNonceOptions {
41
41
  * Signature verification precedes claims; strict iat validity is
42
42
  * nowSeconds - age - skew < iat <= nowSeconds + skew. APIs additionally match
43
43
  * original verified cnf.jkt and SHA-256 ath of the ASCII access token.
44
+ * Independent failure overrides below run their checks and log to console.warn
45
+ * before continuing. Environment defaults are captured at middleware construction.
44
46
  */
45
47
  interface OidcVaultDpopProofOptions {
46
48
  /** Nonempty asymmetric allowlist; default `['ES256']` (P-256). RSA proofs require 2048–4096 bits. */
@@ -51,6 +53,24 @@ interface OidcVaultDpopProofOptions {
51
53
  clockSkewSeconds?: number;
52
54
  /** Off by default. Challenges are at most 512 bytes; retry once with a fresh proof, never reuse its JTI. */
53
55
  nonce?: false | OidcVaultDpopNonceOptions;
56
+ /**
57
+ * Step 6: log failed method/normalized URL checks and continue.
58
+ * Defaults to OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE === 'true'; otherwise false.
59
+ * An explicit boolean overrides the environment for this vault/API policy.
60
+ */
61
+ ignoreTargetFailure?: boolean;
62
+ /**
63
+ * Step 7: log failed proof age/skew checks and continue, retaining replay tracking
64
+ * with a bounded expiry from the current time. A failed clock uses Date.now.
65
+ * Defaults to OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE === 'true'; otherwise false.
66
+ */
67
+ ignoreFreshnessFailure?: boolean;
68
+ /**
69
+ * Step 8: attempt atomic replay reservation, log duplicate/admission/provider
70
+ * failures, and continue. Successful reservations retain their normal lifetime.
71
+ * Defaults to OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE === 'true'; otherwise false.
72
+ */
73
+ ignoreReplayFailure?: boolean;
54
74
  }
55
75
  /**
56
76
  * Opt-in sender constraint. `optional` permits legacy unbound records without
@@ -273,10 +293,11 @@ interface OidcVaultDpopReplayStore {
273
293
  /**
274
294
  * Reserve once through expiry. Duplicate/invalid/expired input returns false
275
295
  * without extending expiry or allocating state. Capacity/provider failures
276
- * throw; callers must fail closed, never evict live reservations. Built-ins
296
+ * throw; callers fail closed by default, never evict live reservations. Built-ins
277
297
  * default to 100000 entries per shared namespace. Expired entries awaiting
278
298
  * bounded cleanup may conservatively occupy capacity. Duplicate detection
279
299
  * precedes capacity rejection; capacity throws `OidcVaultDpopReplayCapacityError`.
300
+ * A proof policy with ignoreReplayFailure logs a failed admission and continues.
280
301
  */
281
302
  reserveDpopProof(input: ReserveDpopProofInput): Promise<boolean>;
282
303
  }
@@ -937,6 +958,10 @@ declare const OIDC_VAULT_ROUTE_PATHS: Record<OidcVaultRouteName, string>;
937
958
  * callbacks/validator methods, URLs, algorithms and secret bytes; the replay
938
959
  * store remains the shared atomic service. Responses carry no-store and fixed
939
960
  * errors; onError privately observes originals and cannot override failures.
961
+ * The independent OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE,
962
+ * OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE and OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE
963
+ * environment defaults are captured at construction; ignored failures log to
964
+ * console.warn. Explicit deviceBinding booleans override those defaults.
940
965
  */
941
966
  declare function createOidcVaultAccessTokenMiddleware(options: OidcVaultAccessTokenMiddlewareOptions): RequestHandler;
942
967
 
@@ -985,6 +1010,10 @@ declare function normalizeOidcVaultBasePath(value?: string): string;
985
1010
  * an enabled nonce secret is copied into private owned storage. Separate opt-in
986
1011
  * `fingerprintRecognition` settings are detached/frozen; recognition enrolls
987
1012
  * only at POST login and checks exchange/refresh before credential mutation.
1013
+ * The independent OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE,
1014
+ * OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE and OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE
1015
+ * environment defaults are captured with the proof policy. Ignored failures
1016
+ * log to console.warn; explicit deviceBinding booleans override those defaults.
988
1017
  */
989
1018
  declare function createOidcVaultMiddleware(options: OidcVaultOptions): Router;
990
1019
 
package/index.d.ts CHANGED
@@ -41,6 +41,8 @@ interface OidcVaultDpopNonceOptions {
41
41
  * Signature verification precedes claims; strict iat validity is
42
42
  * nowSeconds - age - skew < iat <= nowSeconds + skew. APIs additionally match
43
43
  * original verified cnf.jkt and SHA-256 ath of the ASCII access token.
44
+ * Independent failure overrides below run their checks and log to console.warn
45
+ * before continuing. Environment defaults are captured at middleware construction.
44
46
  */
45
47
  interface OidcVaultDpopProofOptions {
46
48
  /** Nonempty asymmetric allowlist; default `['ES256']` (P-256). RSA proofs require 2048–4096 bits. */
@@ -51,6 +53,24 @@ interface OidcVaultDpopProofOptions {
51
53
  clockSkewSeconds?: number;
52
54
  /** Off by default. Challenges are at most 512 bytes; retry once with a fresh proof, never reuse its JTI. */
53
55
  nonce?: false | OidcVaultDpopNonceOptions;
56
+ /**
57
+ * Step 6: log failed method/normalized URL checks and continue.
58
+ * Defaults to OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE === 'true'; otherwise false.
59
+ * An explicit boolean overrides the environment for this vault/API policy.
60
+ */
61
+ ignoreTargetFailure?: boolean;
62
+ /**
63
+ * Step 7: log failed proof age/skew checks and continue, retaining replay tracking
64
+ * with a bounded expiry from the current time. A failed clock uses Date.now.
65
+ * Defaults to OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE === 'true'; otherwise false.
66
+ */
67
+ ignoreFreshnessFailure?: boolean;
68
+ /**
69
+ * Step 8: attempt atomic replay reservation, log duplicate/admission/provider
70
+ * failures, and continue. Successful reservations retain their normal lifetime.
71
+ * Defaults to OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE === 'true'; otherwise false.
72
+ */
73
+ ignoreReplayFailure?: boolean;
54
74
  }
55
75
  /**
56
76
  * Opt-in sender constraint. `optional` permits legacy unbound records without
@@ -273,10 +293,11 @@ interface OidcVaultDpopReplayStore {
273
293
  /**
274
294
  * Reserve once through expiry. Duplicate/invalid/expired input returns false
275
295
  * without extending expiry or allocating state. Capacity/provider failures
276
- * throw; callers must fail closed, never evict live reservations. Built-ins
296
+ * throw; callers fail closed by default, never evict live reservations. Built-ins
277
297
  * default to 100000 entries per shared namespace. Expired entries awaiting
278
298
  * bounded cleanup may conservatively occupy capacity. Duplicate detection
279
299
  * precedes capacity rejection; capacity throws `OidcVaultDpopReplayCapacityError`.
300
+ * A proof policy with ignoreReplayFailure logs a failed admission and continues.
280
301
  */
281
302
  reserveDpopProof(input: ReserveDpopProofInput): Promise<boolean>;
282
303
  }
@@ -937,6 +958,10 @@ declare const OIDC_VAULT_ROUTE_PATHS: Record<OidcVaultRouteName, string>;
937
958
  * callbacks/validator methods, URLs, algorithms and secret bytes; the replay
938
959
  * store remains the shared atomic service. Responses carry no-store and fixed
939
960
  * errors; onError privately observes originals and cannot override failures.
961
+ * The independent OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE,
962
+ * OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE and OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE
963
+ * environment defaults are captured at construction; ignored failures log to
964
+ * console.warn. Explicit deviceBinding booleans override those defaults.
940
965
  */
941
966
  declare function createOidcVaultAccessTokenMiddleware(options: OidcVaultAccessTokenMiddlewareOptions): RequestHandler;
942
967
 
@@ -985,6 +1010,10 @@ declare function normalizeOidcVaultBasePath(value?: string): string;
985
1010
  * an enabled nonce secret is copied into private owned storage. Separate opt-in
986
1011
  * `fingerprintRecognition` settings are detached/frozen; recognition enrolls
987
1012
  * only at POST login and checks exchange/refresh before credential mutation.
1013
+ * The independent OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE,
1014
+ * OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE and OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE
1015
+ * environment defaults are captured with the proof policy. Ignored failures
1016
+ * log to console.warn; explicit deviceBinding booleans override those defaults.
988
1017
  */
989
1018
  declare function createOidcVaultMiddleware(options: OidcVaultOptions): Router;
990
1019
 
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.");
@@ -2309,8 +2347,8 @@ var createDpopReplayPolicy = (options) => {
2309
2347
  throw invalidProof2("DPoP verifier did not return a verified key binding.");
2310
2348
  }
2311
2349
  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.");
2350
+ if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
2351
+ throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
2314
2352
  }
2315
2353
  const binding = Object.freeze({ type, jkt, alg });
2316
2354
  let checkedNow;
@@ -2318,10 +2356,20 @@ var createDpopReplayPolicy = (options) => {
2318
2356
  checkedNow = now();
2319
2357
  if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
2320
2358
  } catch (error) {
2321
- throw replayUnavailable(error);
2359
+ handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
2360
+ checkedNow = Date.now();
2361
+ if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
2362
+ throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
2363
+ }
2364
+ let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2365
+ if (expiresAt === null) {
2366
+ handleDpopCheckFailure(
2367
+ policy.ignoreFreshnessFailure,
2368
+ "freshness",
2369
+ invalidProof2("DPoP signed iat is outside the bounded replay window.")
2370
+ );
2371
+ expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
2322
2372
  }
2323
- const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2324
- if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
2325
2373
  if (noncePolicy) {
2326
2374
  if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
2327
2375
  return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
@@ -2329,18 +2377,19 @@ var createDpopReplayPolicy = (options) => {
2329
2377
  } else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
2330
2378
  throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
2331
2379
  }
2332
- let reserved;
2380
+ let reservationError;
2333
2381
  try {
2334
- reserved = await replayStore.reserveDpopProof({
2382
+ const reserved = await replayStore.reserveDpopProof({
2335
2383
  replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
2336
2384
  expiresAt
2337
2385
  });
2338
2386
  if (typeof reserved !== "boolean")
2339
2387
  throw new TypeError("DPoP replay store returned a non-boolean admission result.");
2388
+ if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2340
2389
  } catch (error) {
2341
- throw replayUnavailable(error);
2390
+ reservationError = replayUnavailable(error);
2342
2391
  }
2343
- if (!reserved) throw invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2392
+ if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2344
2393
  return Object.freeze({ type: "accepted", binding, expiresAt });
2345
2394
  }
2346
2395
  });
@@ -2403,12 +2452,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2403
2452
  now: options.now
2404
2453
  });
2405
2454
  const admit = async (request, proof, expectedJkt) => {
2406
- const targetUrl = targetFor(request.originalUrl);
2407
2455
  const result = await replay.verifyAndReserve(
2408
2456
  () => verifyDpopProof({
2409
2457
  proof,
2410
2458
  method: request.method,
2411
- targetUrl,
2459
+ targetUrl: () => targetFor(request.originalUrl),
2412
2460
  proofOptions: policy,
2413
2461
  expectedJkt
2414
2462
  })
@@ -2589,12 +2637,11 @@ function createOidcVaultAccessTokenMiddleware(options) {
2589
2637
  if (!proof && confirmation)
2590
2638
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2591
2639
  if (proof) {
2592
- const targetUrl = targetFor(originalUrl);
2593
2640
  const admission = await replay.verifyAndReserve(
2594
2641
  () => verifyDpopProof({
2595
2642
  proof,
2596
2643
  method,
2597
- targetUrl,
2644
+ targetUrl: () => targetFor(originalUrl),
2598
2645
  proofOptions: policy,
2599
2646
  accessToken: token,
2600
2647
  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.");
@@ -2261,8 +2299,8 @@ var createDpopReplayPolicy = (options) => {
2261
2299
  throw invalidProof2("DPoP verifier did not return a verified key binding.");
2262
2300
  }
2263
2301
  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.");
2302
+ if (type !== "dpop" || !isCanonicalDpopJkt(jkt) || !isDpopAlgorithm(alg) || !policy.algorithms.includes(alg) || !isPrintableIdentifier(jti) || typeof iat !== "number" || !Number.isSafeInteger(iat) || iat < 0) {
2303
+ throw invalidProof2("DPoP verified key/algorithm or signed iat/JTI shape is invalid.");
2266
2304
  }
2267
2305
  const binding = Object.freeze({ type, jkt, alg });
2268
2306
  let checkedNow;
@@ -2270,10 +2308,20 @@ var createDpopReplayPolicy = (options) => {
2270
2308
  checkedNow = now();
2271
2309
  if (!Number.isSafeInteger(checkedNow) || checkedNow < 0) throw new TypeError("DPoP replay clock is invalid.");
2272
2310
  } catch (error) {
2273
- throw replayUnavailable(error);
2311
+ handleDpopCheckFailure(policy.ignoreFreshnessFailure, "freshness", replayUnavailable(error));
2312
+ checkedNow = Date.now();
2313
+ if (!Number.isSafeInteger(checkedNow) || checkedNow < 0)
2314
+ throw replayUnavailable(new TypeError("DPoP fallback clock is invalid."));
2315
+ }
2316
+ let expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2317
+ if (expiresAt === null) {
2318
+ handleDpopCheckFailure(
2319
+ policy.ignoreFreshnessFailure,
2320
+ "freshness",
2321
+ invalidProof2("DPoP signed iat is outside the bounded replay window.")
2322
+ );
2323
+ expiresAt = checkedNow + (policy.proofMaxAgeSeconds + policy.clockSkewSeconds) * 1e3;
2274
2324
  }
2275
- const expiresAt = getDpopReplayExpiresAt({ ...policy, iat, now: checkedNow });
2276
- if (expiresAt === null) throw invalidProof2("DPoP signed iat is outside the bounded replay window.");
2277
2325
  if (noncePolicy) {
2278
2326
  if (!noncePolicy.verify(nonce, jkt, checkedNow)) {
2279
2327
  return Object.freeze({ type: "nonce-challenge", nonce: noncePolicy.issue(jkt, checkedNow) });
@@ -2281,18 +2329,19 @@ var createDpopReplayPolicy = (options) => {
2281
2329
  } else if (nonce !== void 0 && (typeof nonce !== "string" || nonce.length > DPOP_NONCE_MAX_BYTES || Buffer.byteLength(nonce, "utf8") > DPOP_NONCE_MAX_BYTES)) {
2282
2330
  throw invalidProof2("DPoP signed nonce claim exceeds its string/byte bound.");
2283
2331
  }
2284
- let reserved;
2332
+ let reservationError;
2285
2333
  try {
2286
- reserved = await replayStore.reserveDpopProof({
2334
+ const reserved = await replayStore.reserveDpopProof({
2287
2335
  replayKey: createDpopReplayKey(effectiveNamespace, jkt, jti),
2288
2336
  expiresAt
2289
2337
  });
2290
2338
  if (typeof reserved !== "boolean")
2291
2339
  throw new TypeError("DPoP replay store returned a non-boolean admission result.");
2340
+ if (!reserved) reservationError = invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2292
2341
  } catch (error) {
2293
- throw replayUnavailable(error);
2342
+ reservationError = replayUnavailable(error);
2294
2343
  }
2295
- if (!reserved) throw invalidProof2("DPoP replay reservation was duplicate or no longer live.");
2344
+ if (reservationError) handleDpopCheckFailure(policy.ignoreReplayFailure, "replay", reservationError);
2296
2345
  return Object.freeze({ type: "accepted", binding, expiresAt });
2297
2346
  }
2298
2347
  });
@@ -2355,12 +2404,11 @@ var createVaultRouteProofVerifier = (options, config, backendOrigin, basePath) =
2355
2404
  now: options.now
2356
2405
  });
2357
2406
  const admit = async (request, proof, expectedJkt) => {
2358
- const targetUrl = targetFor(request.originalUrl);
2359
2407
  const result = await replay.verifyAndReserve(
2360
2408
  () => verifyDpopProof({
2361
2409
  proof,
2362
2410
  method: request.method,
2363
- targetUrl,
2411
+ targetUrl: () => targetFor(request.originalUrl),
2364
2412
  proofOptions: policy,
2365
2413
  expectedJkt
2366
2414
  })
@@ -2541,12 +2589,11 @@ function createOidcVaultAccessTokenMiddleware(options) {
2541
2589
  if (!proof && confirmation)
2542
2590
  throw new OidcVaultHttpError(401, "OIDC_VAULT_DPOP_REQUIRED", "DPoP authentication is required.");
2543
2591
  if (proof) {
2544
- const targetUrl = targetFor(originalUrl);
2545
2592
  const admission = await replay.verifyAndReserve(
2546
2593
  () => verifyDpopProof({
2547
2594
  proof,
2548
2595
  method,
2549
- targetUrl,
2596
+ targetUrl: () => targetFor(originalUrl),
2550
2597
  proofOptions: policy,
2551
2598
  accessToken: token,
2552
2599
  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.51.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",