@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/README.md CHANGED
@@ -27,13 +27,29 @@ pnpm add @web-ts-toolkit/express-oidc-vault express
27
27
 
28
28
  For the quick start, also install `@web-ts-toolkit/express-oidc-vault-memory-store`. TypeScript applications need `@types/express` and `@types/node` as development dependencies. Express `>=5.0.0` is the runtime peer dependency.
29
29
 
30
- Use **named imports from the package root**. There is no default export or public subpath API.
30
+ Use **named imports from the package root** for backend APIs; there is no default export. The pure [`/wire` type entry](#browser-wire-types) is available for browser DTO consumers.
31
31
 
32
32
  ## Requirements
33
33
 
34
34
  - Node.js `>=22.12.0`. The published CJS entry (`index.js`) synchronously requires the ESM-only `jose` dependency, which needs Node's `require(esm)` support. That support is enabled by default starting with Node `22.12.0`; earlier Node 22 releases fail to load the CJS root with `ERR_REQUIRE_ESM` unless an experimental flag is passed. Both the CJS (`require`) and ESM (`import`) roots load without experimental flags on every verified runtime (`22.12.0`, `22.18.0`, `22.20.0`, `24.x`, `26.x`).
35
35
  - TypeScript consumers typecheck with `skipLibCheck: false` under strict `NodeNext`/`Bundler` settings. ESM consumers resolve the `import` declaration condition (`index.d.mts`); CommonJS (`.cts`) consumers resolve the `require` condition (`index.d.ts`). Both include the public Express `req.auth` augmentation. Workspace builds place these files under `dist/`; release packaging moves them to the package root and rewrites metadata accordingly. Consumer imports always use the package name.
36
36
 
37
+ ### Browser wire types
38
+
39
+ `@web-ts-toolkit/express-oidc-vault/wire` exports **types only**:
40
+ `OidcVaultLoginInitiationInput`, `OidcVaultLoginInitiationResult`,
41
+ `OidcVaultExchangeResult`, `OidcVaultLogoutResult`, `OidcVaultSessionTransport`,
42
+ `OidcVaultTokenIssueResult`, and `OidcVaultUserProfile`. These are the same
43
+ single-sourced DTOs available at the backend root. Use `import type` from
44
+ `/wire` in browser code; it has no Express augmentation or Node type dependency.
45
+ Its ESM/CJS declarations are `wire.d.mts` / `wire.d.ts`, with empty
46
+ `wire.mjs` / `wire.js` runtime entries and no runtime imports. Both layouts
47
+ follow the workspace/release path rules above.
48
+
49
+ The companion browser client bundles the DTO declarations it needs, so installing
50
+ `@web-ts-toolkit/oidc-vault-dpop-client` needs neither this backend package nor
51
+ Express/Node ambient types. See [Browser DPoP client](#browser-dpop-client).
52
+
37
53
  ## Frontend Storage Policy
38
54
 
39
55
  Default browser-side transport:
@@ -107,19 +123,19 @@ Cookie-authenticated `refresh` and `logout` requests use a fail-closed CSRF poli
107
123
 
108
124
  **Session transport and device-binding mode are independent options.** Opt-in POST login and guarded exchange always need the temporary transaction cookie, including with `sessionTransport: 'body'`. The session cookie is separate and starts at successful exchange. Same-site subdomains can use Lax cookies with credentialed cross-origin requests; cross-origin does not itself mean cross-site. Cross-site SPAs need an explicit HTTPS `transactionCookie: { sameSite: 'none' }` as well as suitable session-cookie settings, credentialed CORS, and a browser policy that permits those cookies.
109
125
 
110
- Recommended frontend boot flow:
126
+ Recommended frontend boot flow (default unbound transport):
111
127
 
112
128
  1. Read `sessionId` from `sessionStorage`.
113
129
  2. If present, call `POST /auth/oidc/refresh` immediately.
114
130
  3. If refresh succeeds, replace the stored `sessionId` with the rotated value and keep the returned `accessToken` in memory only.
115
- 4. If refresh fails, clear `sessionStorage` and treat the user as logged out.
131
+ 4. On a terminal session error, clear the handle and route to sign-in. A transient/proof/nonce failure is not evidence that the session was revoked; retain recovery state and apply the operation's retry contract.
116
132
 
117
133
  If you use `sessionTransport: 'cookie'`, the frontend boot flow becomes simpler:
118
134
 
119
135
  1. Keep `accessToken` in memory only.
120
136
  2. Call `POST /auth/oidc/refresh` on app startup.
121
137
  3. Let the backend read and rotate the session cookie.
122
- 4. Clear in-memory auth state if refresh fails.
138
+ 4. On a terminal session error, clear local auth and route to sign-in. The DPoP client classifies these using `OidcVaultDpopClientError.requiresLogin`, not HTTP status alone.
123
139
 
124
140
  ## Endpoints
125
141
 
@@ -300,7 +316,7 @@ For cookie transport, change `sessionTransport` to `'cookie'` and configure the
300
316
 
301
317
  ### Device-binding policy and issuance contracts
302
318
 
303
- The package root exposes policy, record, local-issuer, guarded-store, POST-login DTO/cookie, and request-aware API contracts. **The backend lifecycle is implemented:** POST initiation, cookie-authenticated callback, original-key exchange/refresh/logout (including unexpired aliases), and API proof/nonce/shared replay enforcement. The [private persistent-key SPA example](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md) includes both transports, an Express/local IdP fixture, and real-browser redirect/reload/IndexedDB/CORS/nonce/cross-tab checks. [Request-aware DPoP APIs](#request-aware-dpop-apis) describes API configuration.
319
+ The package root exposes policy, record, local-issuer, guarded-store, POST-login DTO/cookie, and request-aware API contracts. **The backend lifecycle is implemented:** POST initiation, cookie-authenticated callback, original-key exchange/refresh/logout (including unexpired aliases), and API proof/nonce/shared replay enforcement. The [persistent-key SPA example](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md) includes both transports, an Express/local IdP fixture, and real-browser redirect/reload/IndexedDB/CORS/nonce/cross-tab checks. [Request-aware DPoP APIs](#request-aware-dpop-apis) describes API configuration.
304
320
 
305
321
  `OidcVaultOptions.deviceBinding?: OidcVaultDeviceBindingOptions` is opt-in. Omitting it preserves bearer behavior. An object defaults to `mode: 'optional'`; `required` rejects unbound context. Legacy records are never enrolled by a later proof, and stored bound credentials never become bearer credentials by omission. Construction requires all six `OidcVaultDeviceBindingStoreProvider` capabilities and a static HTTPS `backendOrigin`, except loopback HTTP development.
306
322
 
@@ -318,6 +334,47 @@ The shared nonce policy signs versioned, random HMAC-SHA-256 challenges bound to
318
334
 
319
335
  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
336
 
337
+ #### Per-check DPoP failure overrides
338
+
339
+ 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:
340
+
341
+ | Step | Check | Environment variable | `deviceBinding` option |
342
+ | ---- | ----------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------ |
343
+ | 6 | HTTP method and normalized pinned URL (`htm`/`htu`), including target normalization | `OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE` | `ignoreTargetFailure` |
344
+ | 7 | Signed proof freshness (`iat` age and clock skew) | `OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE` | `ignoreFreshnessFailure` |
345
+ | 8 | Atomic JTI replay admission, including duplicates, capacity, and provider failures | `OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE` | `ignoreReplayFailure` |
346
+
347
+ 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:
348
+
349
+ ```dotenv
350
+ OIDC_VAULT_DPOP_IGNORE_TARGET_FAILURE=true
351
+ OIDC_VAULT_DPOP_IGNORE_FRESHNESS_FAILURE=false
352
+ OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE=true
353
+ ```
354
+
355
+ Explicit booleans on `OidcVaultDeviceBindingOptions` / `OidcVaultApiDeviceBindingOptions` take precedence over the environment for that policy:
356
+
357
+ ```ts
358
+ import type { OidcVaultDeviceBindingOptions } from '@web-ts-toolkit/express-oidc-vault';
359
+
360
+ const deviceBinding: OidcVaultDeviceBindingOptions = {
361
+ mode: 'required',
362
+ ignoreTargetFailure: true,
363
+ ignoreFreshnessFailure: false,
364
+ ignoreReplayFailure: false,
365
+ };
366
+ ```
367
+
368
+ 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.
369
+
370
+ Console output identifies the step, option/environment variable, and original error (including replay provider causes), for example:
371
+
372
+ ```text
373
+ [express-oidc-vault] DPoP step 8 (replay) failed; continuing (ignoreReplayFailure=true; OIDC_VAULT_DPOP_IGNORE_REPLAY_FAILURE).
374
+ ```
375
+
376
+ Environment changes take effect on newly constructed middleware. Ignored failures go directly to the console; `onError` handles failures that stop the request.
377
+
321
378
  ### POST login and browser-authenticated callback
322
379
 
323
380
  Configure the vault with the existing stronger store and an opt-in policy:
@@ -344,10 +401,13 @@ app.use(
344
401
 
345
402
  The initiation wire contract is **`POST <basePath>/login`**, `Content-Type: application/json`, body **`{ returnTo?: string }`**, response **`200 { authorizationUrl: string }`** (`OidcVaultLoginInitiationInput` / `OidcVaultLoginInitiationResult`). The key is selected only by a verified DPoP header. `returnTo` is read only from JSON and uses the existing same-frontend-origin policy; query-string/body JWK/thumbprint shortcuts do not select binding. In optional mode, no proof creates an unbound cookie-guarded POST transaction; any supplied invalid proof fails. Required mode needs a proof and rejects legacy GET login before discovery or state allocation. GET in disabled/optional mode remains the unbound redirect and ignores proof/key shortcuts.
346
403
 
347
- A browser using a persistent key can initiate as follows; the [complete SPA helpers](#persistent-key-dpop-spa-example) generate/persist the key and bound retries:
404
+ A browser using a persistent key can initiate as follows; the [published browser client](#browser-dpop-client) generates/persists the key and bounds retries:
348
405
 
349
406
  ```ts
350
- import type { OidcVaultLoginInitiationInput, OidcVaultLoginInitiationResult } from '@web-ts-toolkit/express-oidc-vault';
407
+ import type {
408
+ OidcVaultLoginInitiationInput,
409
+ OidcVaultLoginInitiationResult,
410
+ } from '@web-ts-toolkit/express-oidc-vault/wire';
351
411
 
352
412
  async function initiateLogin(freshDpopProof: string): Promise<OidcVaultLoginInitiationResult> {
353
413
  // Proof: typ=dpop+jwt, allowed public JWK, POST, pinned absolute login htu,
@@ -395,7 +455,7 @@ Vault POST proofs use the exact method and pinned `backendOrigin` plus externall
395
455
  For example, with a configured local issuer, the exchange response can be checked without backend deep imports (the caller supplies a fresh proof from its persistent original key):
396
456
 
397
457
  ```ts
398
- import type { OidcVaultExchangeResult } from '@web-ts-toolkit/express-oidc-vault';
458
+ import type { OidcVaultExchangeResult } from '@web-ts-toolkit/express-oidc-vault/wire';
399
459
 
400
460
  async function exchangeBoundCode(code: string, freshDpopProof: string): Promise<OidcVaultExchangeResult> {
401
461
  const response = await fetch('https://api.example.com/auth/oidc/exchange', {
@@ -517,7 +577,9 @@ async function initiateRecognizedLogin(getSignal: () => Promise<string | undefin
517
577
 
518
578
  Obtain the same current signal through the source at `/exchange` (body `{ code }`) and `/refresh` (body `{ sessionId }` or cookie `{}`). Login/exchange always use `credentials: 'include'` for the temporary cookie; cookie refresh does too. On a recognition 403, start fresh login rather than a refresh retry loop. CORS must allow the configured fingerprint header and `Content-Type`, explicit trusted origins and credentials; also allow `Authorization`/`DPoP` and expose `DPoP-Nonce`/`WWW-Authenticate` when using DPoP. Cross-site SPAs may need explicit HTTPS `transactionCookie: { sameSite: 'none' }`; browser third-party-cookie policy still applies.
519
579
 
520
- The published browser client [`@web-ts-toolkit/oidc-vault-dpop-client`](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md) ships this generic adapter as `createDeviceFingerprint(source, { headerName? })` plus a structural optional-vendor adapter (not a backend package export):
580
+ ### Published recognition adapter
581
+
582
+ The published browser client [`@web-ts-toolkit/oidc-vault-dpop-client`](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md) ships this generic adapter as `createDeviceFingerprint(source, { headerName? })` plus a structural optional-vendor adapter:
521
583
 
522
584
  ```ts
523
585
  // In your frontend, after choosing to collect recognition:
@@ -564,358 +626,157 @@ Omitting `sessionTtlMs` assigns no default session expiry and retains applicatio
564
626
 
565
627
  ## Frontend Integration Example
566
628
 
567
- ### Standalone body-transport DPoP client
568
-
569
- For the [complete server above](#complete-dpop-server), install `jose` + `idb` in a browser-bundled TypeScript app. This self-contained, **single-tab body-transport** recipe includes persistent key creation, POST login, callback exchange, API proof/nonce retry, refresh after JWT expiry, and logout. It uses only browser libraries and application code; nothing is imported from an Express package at browser runtime. Configure `backendOrigin`/`basePath` to the exact public mount and keep the same key throughout.
570
-
571
- ```sh
572
- pnpm add jose idb
573
- ```
574
-
575
- ```ts
576
- import { base64url, calculateJwkThumbprint, decodeJwt, SignJWT, type JWK } from 'jose';
577
- import { openDB, type DBSchema } from 'idb';
578
-
579
- const backendOrigin = 'https://api.example.com';
580
- const basePath = '/auth/oidc';
581
- const scope = JSON.stringify(['oidc-vault-dpop-v1', location.origin, backendOrigin, basePath]);
582
- const handleName = `dpop:handle:${scope}`;
583
- const pendingName = `dpop:pending:${scope}`;
584
- interface Key {
585
- privateKey: CryptoKey;
586
- publicJwk: JWK;
587
- jkt: string;
588
- }
589
- interface Database extends DBSchema {
590
- keys: { key: string; value: Key };
591
- }
592
- interface Handle {
593
- sessionId: string;
594
- jkt: string;
595
- }
596
- interface Token {
597
- accessToken: string;
598
- expiresAt: number;
599
- jkt: string;
600
- }
601
- let token: Token | undefined; // Never persist the access token.
602
- let refreshPromise: Promise<Token> | undefined;
603
- const nonces = new Map<string, string>();
604
- const database = () =>
605
- openDB<Database>('my-app-dpop', 1, {
606
- upgrade(db) {
607
- db.createObjectStore('keys');
608
- },
609
- });
610
- function clearAuth(): void {
611
- token = undefined;
612
- nonces.clear();
613
- sessionStorage.removeItem(handleName);
614
- sessionStorage.removeItem(pendingName);
615
- }
616
- function requireLogin(): never {
617
- clearAuth();
618
- throw new Error('Sign in again.');
619
- }
620
- function readHandle(): Handle {
621
- const value: unknown = JSON.parse(sessionStorage.getItem(handleName) ?? 'null');
622
- if (
623
- !value ||
624
- typeof value !== 'object' ||
625
- !('sessionId' in value) ||
626
- typeof value.sessionId !== 'string' ||
627
- !value.sessionId ||
628
- !('jkt' in value) ||
629
- typeof value.jkt !== 'string'
630
- )
631
- return requireLogin();
632
- return { sessionId: value.sessionId, jkt: value.jkt };
633
- }
634
- async function readKey(create = false, expectedJkt?: string): Promise<Key> {
635
- if (!isSecureContext || !crypto.subtle || !globalThis.indexedDB) return requireLogin();
636
- const db = await database();
637
- try {
638
- let key = await db.get('keys', scope);
639
- if (!key) {
640
- if (!create) return requireLogin(); // Only a fresh login may create a key.
641
- const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
642
- const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
643
- const publicJwk: JWK = { kty: 'EC', crv: 'P-256', x: jwk.x!, y: jwk.y! };
644
- const candidate: Key = { privateKey: pair.privateKey, publicJwk, jkt: await calculateJwkThumbprint(publicJwk) };
645
- // Crypto outside the transaction; one atomic first-key winner across tabs.
646
- const tx = db.transaction('keys', 'readwrite');
647
- key = await tx.store.get(scope);
648
- if (!key) {
649
- await tx.store.add(candidate, scope);
650
- key = candidate;
651
- }
652
- await tx.done;
653
- }
654
- const algorithm = key.privateKey?.algorithm as EcKeyAlgorithm | undefined;
655
- if (
656
- !(key.privateKey instanceof CryptoKey) ||
657
- key.privateKey.type !== 'private' ||
658
- key.privateKey.extractable ||
659
- algorithm?.name !== 'ECDSA' ||
660
- algorithm.namedCurve !== 'P-256' ||
661
- key.publicJwk?.kty !== 'EC' ||
662
- key.publicJwk.crv !== 'P-256' ||
663
- Object.keys(key.publicJwk).length !== 4 ||
664
- (await calculateJwkThumbprint(key.publicJwk)) !== key.jkt ||
665
- (expectedJkt !== undefined && expectedJkt !== key.jkt)
666
- )
667
- return requireLogin();
668
- const publicKey = await crypto.subtle.importKey(
669
- 'jwk',
670
- key.publicJwk,
671
- { name: 'ECDSA', namedCurve: 'P-256' },
672
- true,
673
- ['verify'],
674
- );
675
- const data = new TextEncoder().encode('dpop-key-pair-check');
676
- const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key.privateKey, data);
677
- if (!(await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, signature, data)))
678
- return requireLogin();
679
- return key;
680
- } finally {
681
- db.close();
682
- }
683
- }
684
- function target(url: URL): string {
685
- // URL already normalizes host/default port/dot segments. Normalize unreserved
686
- // escapes before final dot removal; preserve reserved escapes such as %2F.
687
- const path = url.pathname.replace(/%([0-9a-f]{2})/gi, (_, hex: string) => {
688
- const c = String.fromCharCode(parseInt(hex, 16));
689
- return /^[A-Za-z0-9._~-]$/.test(c) ? c : `%${hex.toUpperCase()}`;
690
- });
691
- return new URL(`${url.origin}${path}`).href; // No query or fragment.
692
- }
693
- export async function signRequestProof(
694
- key: Key,
695
- method: string,
696
- url: URL,
697
- accessToken?: string,
698
- nonce?: string,
699
- ): Promise<string> {
700
- const ath =
701
- accessToken === undefined
702
- ? undefined
703
- : base64url.encode(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken))));
704
- return new SignJWT({
705
- htm: method,
706
- htu: target(url),
707
- iat: Math.floor(Date.now() / 1000),
708
- jti: base64url.encode(crypto.getRandomValues(new Uint8Array(16))),
709
- ...(ath === undefined ? {} : { ath }),
710
- ...(nonce === undefined ? {} : { nonce }),
711
- })
712
- .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk })
713
- .sign(key.privateKey);
714
- }
715
- function rememberNonce(space: string, response: Response): boolean {
716
- const nonce = response.headers.get('DPoP-Nonce');
717
- if (!nonce || nonce.length > 512 || !/^[\x20-\x7e]+$/.test(nonce)) return false;
718
- nonces.set(space, nonce);
719
- return true;
720
- }
721
- async function post(route: 'login' | 'exchange' | 'refresh' | 'logout', body: object, key: Key): Promise<unknown> {
722
- const url = new URL(`${backendOrigin}${basePath}/${route}`);
723
- const space = JSON.stringify(['vault', scope, key.jkt]);
724
- for (let attempt = 0; attempt < 2; attempt++) {
725
- key = await readKey(false, key.jkt);
726
- const proof = await signRequestProof(key, 'POST', url, undefined, nonces.get(space));
727
- const response = await fetch(url, {
728
- method: 'POST',
729
- redirect: 'error',
730
- cache: 'no-store',
731
- credentials: route === 'login' || route === 'exchange' ? 'include' : 'omit',
732
- headers: { 'Content-Type': 'application/json', DPoP: proof },
733
- body: JSON.stringify(body),
734
- });
735
- const value: unknown = await response.json();
736
- if (response.ok) return value;
737
- const code = value && typeof value === 'object' && 'code' in value ? value.code : undefined;
738
- if (
739
- response.status === 400 &&
740
- code === 'OIDC_VAULT_USE_DPOP_NONCE' &&
741
- attempt === 0 &&
742
- rememberNonce(space, response)
743
- )
744
- continue;
745
- if (code === 'OIDC_VAULT_INVALID_SESSION' || code === 'OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED')
746
- return requireLogin();
747
- throw new Error('Vault request failed.'); // Do not silently retry proof/503/network failures.
748
- }
749
- throw new Error('Repeated nonce challenge.');
750
- }
751
- function accept(value: unknown, key: Key): Token {
752
- if (
753
- !value ||
754
- typeof value !== 'object' ||
755
- !('accessToken' in value) ||
756
- typeof value.accessToken !== 'string' ||
757
- !('tokenType' in value) ||
758
- value.tokenType !== 'DPoP' ||
759
- !('expiresIn' in value) ||
760
- typeof value.expiresIn !== 'number' ||
761
- !Number.isSafeInteger(value.expiresIn) ||
762
- value.expiresIn < 0 ||
763
- !('sessionId' in value) ||
764
- typeof value.sessionId !== 'string'
765
- )
766
- return requireLogin();
767
- // Check the trusted server's response contract; this decoding is NOT JWT authentication.
768
- const cnf = decodeJwt(value.accessToken).cnf as { jkt?: unknown } | undefined;
769
- if (cnf?.jkt !== key.jkt) return requireLogin();
770
- sessionStorage.setItem(handleName, JSON.stringify({ sessionId: value.sessionId, jkt: key.jkt }));
771
- token = { accessToken: value.accessToken, expiresAt: Date.now() + value.expiresIn * 1000, jkt: key.jkt };
772
- return token;
773
- }
774
- export async function signIn(): Promise<void> {
775
- clearAuth();
776
- const key = await readKey(true);
777
- sessionStorage.setItem(pendingName, key.jkt);
778
- const value = await post('login', { returnTo: '/callback' }, key);
779
- if (
780
- !value ||
781
- typeof value !== 'object' ||
782
- !('authorizationUrl' in value) ||
783
- typeof value.authorizationUrl !== 'string'
784
- )
785
- return requireLogin();
786
- const url = new URL(value.authorizationUrl);
787
- if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return requireLogin();
788
- location.assign(url.href);
789
- }
790
- export function refresh(): Promise<Token> {
791
- if (!refreshPromise)
792
- refreshPromise = (async () => {
793
- const handle = readHandle();
794
- const key = await readKey(false, handle.jkt);
795
- return accept(await post('refresh', { sessionId: handle.sessionId }, key), key);
796
- })().finally(() => {
797
- refreshPromise = undefined;
798
- });
799
- return refreshPromise; // No access token/Authorization/ath needed, even after expiry.
800
- }
801
- export async function bootstrap(): Promise<void> {
802
- const url = new URL(location.href);
803
- const code = url.searchParams.get('code');
804
- if (code) {
805
- url.searchParams.delete('code');
806
- history.replaceState(null, '', url.href);
807
- const pending = sessionStorage.getItem(pendingName);
808
- if (!pending) return requireLogin();
809
- const key = await readKey(false, pending);
810
- accept(await post('exchange', { code }, key), key);
811
- sessionStorage.removeItem(pendingName);
812
- } else if (sessionStorage.getItem(handleName)) {
813
- await refresh();
814
- }
815
- }
816
- export async function getProfile(): Promise<unknown> {
817
- const url = new URL(`${backendOrigin}/api/profile`); // Fixed API scope; no arbitrary credential target.
818
- let credential = token;
819
- let refreshed = false;
820
- if (!credential || credential.expiresAt <= Date.now()) {
821
- credential = await refresh();
822
- refreshed = true;
823
- }
824
- const space = JSON.stringify(['api', url.origin, 'app-api-v1', credential.jkt]);
825
- let nonceRetried = false;
826
- for (;;) {
827
- const key = await readKey(false, credential.jkt);
828
- const proof = await signRequestProof(key, 'GET', url, credential.accessToken, nonces.get(space));
829
- const response = await fetch(url, {
830
- redirect: 'error',
831
- credentials: 'omit',
832
- cache: 'no-store',
833
- headers: { Authorization: `DPoP ${credential.accessToken}`, DPoP: proof },
834
- });
835
- const error = /(?:^|,\s*)DPoP\s+error="([a-z_]+)"(?:,|$)/i.exec(
836
- response.headers.get('WWW-Authenticate') ?? '',
837
- )?.[1];
838
- if (response.status === 401 && error === 'use_dpop_nonce' && !nonceRetried && rememberNonce(space, response)) {
839
- nonceRetried = true;
840
- await response.body?.cancel();
841
- continue;
842
- }
843
- if (response.status === 401 && error === 'invalid_token' && !refreshed) {
844
- refreshed = true;
845
- await response.body?.cancel();
846
- credential = await refresh();
847
- continue;
848
- }
849
- if (!response.ok) throw new Error('Protected API request failed.');
850
- return response.json();
851
- }
852
- }
853
- export async function signOut(): Promise<void> {
854
- const handle = readHandle();
855
- const key = await readKey(false, handle.jkt);
856
- try {
857
- await post('logout', { sessionId: handle.sessionId }, key);
858
- } finally {
859
- clearAuth();
860
- }
861
- }
862
- ```
863
-
864
- Wire it to a sign-in button (`signIn()`), callback/app startup (`bootstrap()`), a profile action (`getProfile()`), and logout (`signOut()`). Keep these operations sequential around login/logout. Storage/crypto failures stop the operation; clear frontend auth and request fresh login, without an ephemeral-key or Bearer fallback. This recipe's body handle is **tab-local**: atomic key creation does not coordinate shared refresh authority. Do not copy handles or rely on opener-cloned sessionStorage. The example helpers below add cookie-mode Web Locks/BroadcastChannel, richer scoped fetch/body retry rules, current recognition and error handling.
865
-
866
- ### Persistent-key DPoP SPA example
629
+ ### Browser DPoP client
867
630
 
868
631
  For a bound flow, install the published browser client
869
632
  [`@web-ts-toolkit/oidc-vault-dpop-client`](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md)
870
633
  in your frontend (it brings `jose` + `idb` as runtime dependencies) and use its
871
634
  named root exports. These are **versioned package exports**, separate from the
872
- Express package. The complete example app's server uses required DPoP, a local
873
- issuer, and API request-aware validation; its default mounts are
874
- `/auth/oidc/body` and `/auth/oidc/cookie`. Your own core mount can use the
875
- default `/auth/oidc`.
635
+ Express package. This complete browser module pairs with the
636
+ [DPoP server above](#complete-dpop-server) at `/auth/oidc`:
876
637
 
877
638
  ```sh
878
639
  pnpm add @web-ts-toolkit/oidc-vault-dpop-client
879
640
  ```
880
641
 
881
642
  ```ts
882
- import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
643
+ import {
644
+ createOidcVaultDpopSession,
645
+ fetchWithDpop,
646
+ OidcVaultDpopClientError,
647
+ } from '@web-ts-toolkit/oidc-vault-dpop-client';
883
648
 
884
- const backendOrigin = 'http://127.0.0.1:4318';
885
- const session = createOidcVaultDpopSession({ backendOrigin, basePath: '/auth/oidc/body', sessionTransport: 'body' }); // Cookie: basePath '/auth/oidc/cookie', sessionTransport 'cookie'.
649
+ const backendOrigin = 'https://api.example.com';
650
+ const session = createOidcVaultDpopSession({
651
+ backendOrigin,
652
+ basePath: '/auth/oidc',
653
+ sessionTransport: 'body',
654
+ requestTimeoutMs: 15_000,
655
+ maxResponseBytes: 1_048_576,
656
+ });
886
657
 
887
- export const signIn = (): Promise<void> => session.login('/callback?transport=body');
658
+ export const signIn = (): Promise<void> => session.login('/callback');
888
659
 
889
- export async function bootstrapBoundAuth(): Promise<void> {
890
- const url = new URL(location.href);
891
- const code = url.searchParams.get('code');
892
- if (code) {
893
- url.searchParams.delete('code');
894
- history.replaceState(null, '', url.href);
895
- await session.exchange(code);
896
- } else {
897
- await session.refresh(); // Persisted key; no access-token Authorization/ath.
660
+ export async function bootstrapBoundAuth(): Promise<'ready' | 'sign-in'> {
661
+ try {
662
+ const url = new URL(location.href);
663
+ const code = url.searchParams.get('code');
664
+ if (code !== null) {
665
+ url.searchParams.delete('code');
666
+ history.replaceState(null, '', url.href);
667
+ await session.exchange(code);
668
+ } else {
669
+ await session.refresh(); // Persisted key; no access-token Authorization/ath.
670
+ }
671
+ return 'ready';
672
+ } catch (error) {
673
+ if (error instanceof OidcVaultDpopClientError && error.requiresLogin) return 'sign-in';
674
+ throw error; // Retain state on transient/proof/nonce errors; no blanket retry.
898
675
  }
899
676
  }
900
677
 
901
- export async function getBoundProfile(): Promise<unknown> {
678
+ export async function getBoundProfile(signal?: AbortSignal): Promise<unknown> {
902
679
  const response = await fetchWithDpop(
903
- { session, apis: [{ origin: backendOrigin, replayNamespace: 'oidc-vault-dpop-example-api' }] },
680
+ { session, apis: [{ origin: backendOrigin, pathPrefix: '/api', replayNamespace: 'app-api-v1' }] },
904
681
  `${backendOrigin}/api/profile`,
682
+ { signal },
905
683
  );
906
684
  if (!response.ok) throw new Error('Protected API request failed.');
907
685
  return response.json();
908
686
  }
909
687
  export const signOut = (): Promise<void> => session.logout();
688
+
689
+ // Explicit user action for an existing corrupt key; ordinary sign-in needs no reset.
690
+ export async function recoverBrowserKey(): Promise<void> {
691
+ await session.resetKey();
692
+ await signIn();
693
+ }
910
694
  ```
911
695
 
912
- To run this flow live against the example vault/API server and local IdP (repository root, two terminals: `pnpm --filter oidc-vault-dpop-example dev:server`, then `pnpm --filter oidc-vault-dpop-example dev`), open http://127.0.0.1:4317/?transport=body (or `cookie`).
696
+ For cookie transport, select `sessionTransport: 'cookie'` on both sides and the
697
+ matching public mount. The runnable [SPA + vault/API + local IdP example](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md)
698
+ uses separate `/auth/oidc/body` and `/auth/oidc/cookie` mounts and cookie names.
699
+
700
+ `login()` atomically persists a non-extractable P-256 key before POST/navigation.
701
+ Exchange/refresh/API/logout use that existing key; loss/change requires fresh
702
+ login, while an existing unusable key needs explicit `resetKey()` then login.
703
+ Tokens stay in memory, body handles/pending login in tab-local sessionStorage,
704
+ and cookie handles in backend HttpOnly cookies. All flows require secure-context
705
+ Web Crypto and IndexedDB CryptoKey persistence; cookie mode additionally needs
706
+ Web Locks/BroadcastChannel. There is no implicit replacement-key/Bearer fallback.
707
+
708
+ Both transports use a lifecycle FIFO, with refresh sharing until an intervening
709
+ mutation. Cookie mode adds same-origin/scope locks and a bounded 180 ms live-peer
710
+ token wait. `clear()` immediately invalidates local auth/owned HTTP and preserves
711
+ the key; `dispose()` closes the context while preserving committed reload state;
712
+ `resetKey()` atomically deletes only this scope's key/coordination records.
713
+ Await clear/reset before disposing if their persistence must finish. Only
714
+ `logout()` requests server revocation. Reset cannot remove HttpOnly cookies,
715
+ revoke old sessions/JWTs, or undo already-started API work; other sessions sharing
716
+ the deleted key need fresh login.
717
+
718
+ Vault attempts default to **15,000 ms** through headers/body/JSON parsing and
719
+ **1 MiB** of streamed response bytes. These limits exclude queue/storage/crypto/
720
+ recognition/other lock owners and cannot preempt synchronous JavaScript or a
721
+ suspended tab. Clear/reset/dispose abort owned HTTP waits; `fetchWithDpop` and
722
+ `createDpopProof` accept a caller `signal`. Aborting one API waiter does not
723
+ cancel shared refresh. API response bodies remain caller-owned and outside the
724
+ vault limits. A lost mutation response leaves server commit/Set-Cookie uncertain:
725
+ no automatic timeout/network retry; a lost body-handle rotation may need login.
726
+
727
+ `fetchWithDpop` owns auth headers and rejects redirects. API `pathPrefix`
728
+ uses longest normalized segment matching; omission or `/` is origin-wide.
729
+ Outbound paths share proof normalization (unreserved escapes/dots normalize,
730
+ reserved separators stay encoded); query data stays intact. Use separate
731
+ `replayNamespace`s for independent gateway services. Valid response nonces are
732
+ learned even from success or a single-attempt mutation for the next explicit
733
+ call. Each cache holds 64 space/key tuples, evicting the oldest remembered; it
734
+ does not infer nonce TTLs. Vaults retry a usable nonce once, then report
735
+ non-terminal `DPOP_NONCE_RETRY_EXHAUSTED`; retry-enabled APIs allow one nonce
736
+ retry and one `invalid_token` refresh budget. Final API responses belong to the
737
+ caller. Mutations stay single-attempt unless `retry: 'idempotent'` is justified
738
+ by the server operation. Fresh proofs/JTIs are mandatory on every attempt.
739
+
740
+ Use `requiresLogin`, not every HTTP 401, for session recovery: terminal session,
741
+ browser/key-binding and enrolled recognition errors clear auth; recoverable
742
+ proof/nonce/transient errors retain it. Unknown vault codes become fixed
743
+ `OIDC_REQUEST_FAILED`; API HTTP failures are returned as `Response`s. A valid
744
+ zero-lifetime refresh retains its rotated handle but `fetchWithDpop` stops with
745
+ non-terminal `DPOP_ACCESS_TOKEN_EXPIRED` instead of sending an expired token or
746
+ looping. Full tables and upgrade guidance ship in the
747
+ [client README](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md).
913
748
 
914
- `login()` atomically creates a non-extractable ES256/P-256 private CryptoKey in IndexedDB **before** POST initiation/navigation; callback, exchange, refresh and API use that same origin/backend/basePath-scoped key. Key loss requires fresh login. Tokens stay in memory; body handles use sessionStorage, cookie handles stay backend-only. Login/exchange always include credentials for the temporary cookie. Cookie-mode refresh uses a per-context promise plus Web Locks/BroadcastChannel winner-token delivery, with current-recognition matching and fresh independent API proofs. All flows require a secure context, Web Crypto, sessionStorage and IndexedDB CryptoKey structured clone; **cookie mode additionally requires Web Locks/BroadcastChannel**. Unsupported capabilities fail explicitly, with no ephemeral-key/Bearer fallback.
749
+ The example has real redirect/reload/cookie/CORS/key/expiry/retry/tab coverage on **Chromium 151 and Firefox 153**. Its tests use cross-origin **same-site loopback** HTTP. WebKit/Safari is not certified in the recorded environment because required Linux libraries were unavailable; arbitrary HTTPS cross-site/third-party-cookie policies and external IdPs need deployment-specific checks. Browser locks coordinate only the same frontend origin/storage partition/vault scope; they do not supply a distributed backend refresh lease.
915
750
 
916
- `fetchWithDpop` uses mandatory `Authorization: DPoP`, a fresh proof/ath each attempt, exact configured API origins and `redirect: 'error'`. It allows one nonce retry total and at most one refresh/retry for `DPoP error="invalid_token"`; refresh itself has no ath and works after JWT expiry. Mutating methods are single-attempt unless an authorized server-idempotent contract is explicitly selected with `retry: 'idempotent'`; retry-enabled bodies must be replayable. CORS allows explicit origins/credentials and Content-Type/Authorization/DPoP/configured fingerprint, exposing DPoP-Nonce/WWW-Authenticate. See the [example README](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md) for exact wire DTOs, external-IdP environment settings, commands and deployment limits.
751
+ ### Low-level proof signing for an owned request
917
752
 
918
- The example has real redirect/reload/cookie/CORS/key/expiry/retry/tab coverage on **Chromium 151 and Firefox 153**. Its tests use cross-origin **same-site loopback** HTTP. WebKit/Safari is not certified in the recorded environment because required Linux libraries were unavailable; arbitrary HTTPS cross-site/third-party-cookie policies and external IdPs need deployment-specific checks. Browser locks coordinate only the same frontend origin/storage partition/vault scope; they do not supply a distributed backend refresh lease.
753
+ Prefer `fetchWithDpop` for session-backed APIs. When your application already
754
+ owns key/session validation and the HTTP/retry policy, the root proof helper
755
+ can sign one attempt. Supply a live same-key JWT and obtain the key via
756
+ `session.getKey()` immediately before use; recreate the proof for every retry.
757
+ This example intentionally returns one response for the caller to handle:
758
+
759
+ ```ts
760
+ import { createDpopProof, type DpopKey } from '@web-ts-toolkit/oidc-vault-dpop-client';
761
+
762
+ export async function getWithProof(
763
+ key: DpopKey,
764
+ accessToken: string,
765
+ nonce?: string,
766
+ signal?: AbortSignal,
767
+ ): Promise<Response> {
768
+ const url = 'https://api.example.com/api/profile?view=summary';
769
+ const proof = await createDpopProof(key, { method: 'GET', url, accessToken, nonce, signal });
770
+ return fetch(url, {
771
+ method: 'GET',
772
+ credentials: 'omit',
773
+ redirect: 'error',
774
+ cache: 'no-store',
775
+ signal,
776
+ headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof },
777
+ });
778
+ }
779
+ ```
919
780
 
920
781
  ### Default unbound bearer frontend
921
782
 
@@ -1443,9 +1304,9 @@ DPoP replay is a reservation per accepted proof, including API traffic; it is su
1443
1304
 
1444
1305
  `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
1306
 
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.
1307
+ 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
1308
 
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`.
1309
+ 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
1310
 
1450
1311
  | Provider | Admission/expiry work |
1451
1312
  | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1653,9 +1514,13 @@ That local token is separate from the upstream IdP access token:
1653
1514
 
1654
1515
  `tokenIssuer.issue` must resolve to a non-null, non-array object with:
1655
1516
 
1656
- - `accessToken`: nonempty opaque string, returned verbatim without trimming or a new whitespace policy;
1657
- - `expiresIn`: finite nonnegative safe-integer seconds, from 0 through `Number.MAX_SAFE_INTEGER`;
1658
- - `tokenType`: exact `'Bearer'` or `'DPoP'`. **Unbound issuance** permits only `'Bearer'` or omission; omitted/undefined stays absent in JSON. **Bound issuance** requires exact `'DPoP'` and a compact signed JWT carrying matching `cnf.jkt`. Null, lowercase, whitespace variants, and a bound Bearer result are invalid.
1517
+ - `accessToken`: nonempty opaque string for unbound issuance, returned verbatim without trimming; bound issuance requires the JWT profile below;
1518
+ - `expiresIn`: finite nonnegative safe-integer seconds, from 0 through `Number.MAX_SAFE_INTEGER` for unbound issuance. Bound issuance additionally requires `expiresIn * 1000` to be safe;
1519
+ - `tokenType`: exact `'Bearer'` or `'DPoP'`. **Unbound issuance** permits only `'Bearer'` or omission; omitted/undefined stays absent in JSON. **Bound issuance** requires exact `'DPoP'`. Null, lowercase, whitespace variants, and a bound Bearer result are invalid.
1520
+
1521
+ The **bound local-JWT profile** is a compact signed JWT with three nonempty canonical unpadded base64url segments, a nonempty signing `alg` other than `none` (no `b64: false` payload), exactly `cnf: { jkt }`, and a mandatory `exp`. The `jkt` must be the matching canonical RFC 7638 SHA-256 thumbprint (43 base64url characters with canonical final bits); additional confirmation fields/methods are unsupported. `exp` is a nonnegative safe integer in epoch **seconds**, and `exp * 1000` must also be a safe integer (so `exp <= Math.floor(Number.MAX_SAFE_INTEGER / 1000)`). Both the issuer boundary and built-in JWT validator enforce this profile.
1522
+
1523
+ `expiresIn: 0` remains valid output: the response can still deliver a rotated body handle or set a rotated cookie. It supplies no live reusable API/peer credential. The companion browser client caps its deadline by both `now + expiresIn * 1000` and JWT `exp * 1000`, checking the clock, conversions, and addition for safe nonnegative integers before clamping. It retains zero-lifetime response handles and rejects expired effective peer credentials. Browser decoding and trusted issuer output checks are **not JWT authentication**; JOSE/API expiry enforcement remains authoritative.
1659
1524
 
1660
1525
  Only these three fields are copied once into a fresh result. Extra fields (including upstream tokens, `metadata`, `sessionId`, `user`, and `toJSON`) are ignored without evaluating their getters. The vault supplies the response session ID/profile. Omitting `tokenIssuer` is supported and returns no local token fields.
1661
1526
 
@@ -1667,11 +1532,14 @@ This projection contains accidental result extensions. Issuers/hooks remain trus
1667
1532
 
1668
1533
  ## Migration And Behavior Changes
1669
1534
 
1535
+ - Declaration upgrade: browser DTO consumers can switch type-only imports from the backend root to `@web-ts-toolkit/express-oidc-vault/wire`. All seven DTO root imports and backend `req.auth` augmentation remain supported in strict ESM/CJS consumers. The companion client now ships DOM-only declarations and requires Node **>=22.12.0** for CJS tooling.
1536
+ - Browser-client upgrade: use the [published client quickstart](#browser-dpop-client) in place of the old standalone implementation. Its shipped [upgrade notes](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md#upgrade-notes) cover finite HTTP budgets/cancellation, FIFO/cleanup, terminal versus recoverable errors, single-attempt nonce learning, gateway path normalization and explicit scoped key recovery. Existing browser keys keep their database name/schema.
1670
1537
  - Optional-only endpoint settings previously ignored now select manual mode and fail without the complete manual set. Supply all required manual values or remove endpoint settings to use discovery. Correct malformed optional discovery capabilities at the provider, or omit unsupported fields.
1671
1538
  - DPoP remains opt-in: omitted policy preserves unbound behavior; an object defaults to optional, required rejects legacy unbound use. New binding is selected only by proof-aware POST login. Disabling DPoP or omitting later proofs never downgrades existing bound records/JWTs. Recognition remains a separate opt-in with POST-only enrollment; a changed enrolled signal requires fresh login.
1672
1539
  - Invalid transaction/code TTLs previously had store-dependent behavior; supply positive safe-integer milliseconds. `sessionTtlMs` is opt-in for new sessions and never renews on refresh. Custom clocks are now sampled during construction.
1673
1540
  - Route each session to its owning issuer/client configuration. Known foreign live sessions now fail with 401. Correct inaccurate stored identity only from trusted provenance or require login again; do not remove identity fields to bypass the guard. Legacy omissions and shared code/alias limits remain as described above.
1674
1541
  - Issuers must return the declared local credential shape; previously accepted malformed results now fail with rollback. Extra result properties no longer extend/override JSON responses.
1542
+ - Bound JWT upgrade: emit exactly `cnf: { jkt }`, canonical compact encoding, and a safe integer `exp` with safe milliseconds; bound `expiresIn` must also convert safely to milliseconds. Unsupported confirmation extensions and missing/unsafe expiry now fail issuance with the same sanitized HTTP 500/original-lineage rollback. Already-issued invalid bound JWTs fail both built-in validator methods **before `mapClaims`**. Generic unbound JWT optional-exp behavior and opaque unbound issuance remain compatible; zero-lifetime responses still carry session-rotation results.
1675
1543
  - Provider network/reset failures now produce sanitized endpoint-specific 502s instead of generic internal errors. Cancellation no longer waits for an uncooperative cleanup promise. Alias-retention wording reflects existing SVH-05 behavior, with no store migration.
1676
1544
 
1677
1545
  ## Access Token Validation Middleware
@@ -1826,6 +1694,8 @@ If you need a custom mapping, pass `mapClaims(...)` to `createOidcVaultJwtAccess
1826
1694
 
1827
1695
  The helper implements both `validate(token)` and `validateWithRequest({ token, scheme, req })`. Its legacy result omits confirmation only for an originally unbound JWT, and reports a bound confirmation so even binding-disabled vault API middleware refuses a known bound-token downgrade. The helper itself authenticates JWT data; proof enforcement is performed by the middleware below.
1828
1696
 
1697
+ In **both methods**, a cryptographically verified bound JWT must also satisfy the [bound local-JWT profile](#local-issuer-result-contract), including mandatory safe `exp`, before `mapClaims` runs. A mapper cannot repair missing expiry or unsupported confirmation. JOSE still verifies signature, expiration, and configured issuer/audience/algorithms first. Genuinely unbound JWTs retain JOSE's generic optional-exp contract; this bound profile does not impose a new token-purpose or mandatory-exp policy on them.
1698
+
1829
1699
  ### Request-aware DPoP APIs
1830
1700
 
1831
1701
  **Every API accepting a bound JWT must enforce the request proof.** A signature-only JWT verifier remains bearer-equivalent. Configure the API middleware with `OidcVaultApiDeviceBindingOptions` and a request-aware validator. This API path works with local JWTs whose verified `cnf.jkt` binds the client key. Vault POST login/callback persist the initiating key; exchange/refresh pass its verified request context to the local issuer, and bound logout requires that same key.
@@ -1891,7 +1761,7 @@ Authorization: DPoP <local-access-token>
1891
1761
  DPoP: <fresh-signed-proof-jwt>
1892
1762
  ```
1893
1763
 
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.
1764
+ 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
1765
 
1896
1766
  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
1767
 
@@ -1899,7 +1769,7 @@ API replay uses the shared `['api', normalizedPublicOrigin, replayNamespace]` pr
1899
1769
 
1900
1770
  #### API errors and nonce retry
1901
1771
 
1902
- For the default ES256 policy (`algs` lists the configured allowlist in order):
1772
+ With default failure enforcement and the ES256 policy (`algs` lists the configured allowlist in order):
1903
1773
 
1904
1774
  | Condition | HTTP / code | `WWW-Authenticate` |
1905
1775
  | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------- |
@@ -1933,7 +1803,7 @@ Vault routes and API middleware return sanitized `{ code, message }`. Invalid pr
1933
1803
  | 400 | `OIDC_VAULT_INVALID_FINGERPRINT` | `Fingerprint signal is invalid.` |
1934
1804
  | 403 | `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` | `Browser recognition changed; sign in again.` |
1935
1805
 
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`.
1806
+ 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
1807
 
1938
1808
  Recommended separation:
1939
1809