@web-ts-toolkit/oidc-vault-dpop-client 0.51.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.
Files changed (7) hide show
  1. package/README.md +483 -73
  2. package/index.d.mts +182 -81
  3. package/index.d.ts +182 -81
  4. package/index.js +943 -226
  5. package/index.mjs +946 -229
  6. package/llms.txt +52 -37
  7. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@web-ts-toolkit/oidc-vault-dpop-client`
2
2
 
3
- Browser-only DPoP client for [`@web-ts-toolkit/express-oidc-vault`](../express-oidc-vault/README.md)
3
+ Browser-only DPoP client for [`@web-ts-toolkit/express-oidc-vault`](https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault)
4
4
  vault flows, with `body` and `cookie` session transports. Framework-agnostic
5
5
  vanilla TypeScript: persistent non-extractable P-256 keys, per-attempt proofs,
6
6
  bounded nonce/refresh retries, and tab coordination for cookie sessions.
@@ -12,6 +12,11 @@ and — cookie transport only — `navigator.locks` plus `BroadcastChannel`) laz
12
12
  behind runtime feature asserts, never at module import time. There is no
13
13
  `express`, no `node:*`, and no default export.
14
14
 
15
+ **Guide:** [Quickstart](#quickstart-body-transport) ·
16
+ [Lifecycle/cleanup](#lifecycle-concurrency-and-cleanup) ·
17
+ [HTTP bounds](#http-bounds-and-cancellation) · [Recovery](#key-loss-and-errors) ·
18
+ [Gateway scopes](#multiple-services-behind-a-gateway) · [Upgrade notes](#upgrade-notes)
19
+
15
20
  ## Installation
16
21
 
17
22
  ```sh
@@ -22,6 +27,23 @@ pnpm add @web-ts-toolkit/oidc-vault-dpop-client
22
27
  single install above is enough. Do not install them separately unless your own
23
28
  code imports them directly.
24
29
 
30
+ **Requirements:** secure-context Web Crypto, IndexedDB `CryptoKey` persistence,
31
+ and sessionStorage. Cookie transport also needs Web Locks + BroadcastChannel.
32
+ Feature checks run when creating/using a session, so importing the module is
33
+ safe before browser startup. Keep one lazy session per tab/vault mount.
34
+
35
+ Node tooling/runtime imports require **Node >=22.12.0**: the CJS entry
36
+ synchronously requires ESM-only `jose`, using Node's default `require(esm)`
37
+ support. Node 22.0–22.11 cannot load it without an experimental flag. Node
38
+ importability does not supply the browser storage needed to create a session.
39
+
40
+ Strict DOM-only TypeScript consumers can use `types: []` and
41
+ `skipLibCheck: false` with Bundler or NodeNext resolution. ESM resolves
42
+ `index.d.mts`, CJS resolves `index.d.ts`; both are self-contained, without Node
43
+ types, Express augmentation, or a backend package dependency. In workspace/npm
44
+ source-tree packs these files live under `dist/`; release packaging flattens
45
+ them and rewrites the export map. Always import by package name.
46
+
25
47
  ## Canonical imports
26
48
 
27
49
  Named root imports only; no default export, no deep `dist/*` or `src/*` imports.
@@ -62,7 +84,11 @@ mount (the core default is `/auth/oidc`; the example app mounts
62
84
  `/auth/oidc/body` and `/auth/oidc/cookie` side by side — pick the one you call).
63
85
 
64
86
  ```ts
65
- import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
87
+ import {
88
+ createOidcVaultDpopSession,
89
+ fetchWithDpop,
90
+ OidcVaultDpopClientError,
91
+ } from '@web-ts-toolkit/oidc-vault-dpop-client';
66
92
 
67
93
  const backendOrigin = 'https://api.example.com';
68
94
  const session = createOidcVaultDpopSession({
@@ -77,15 +103,21 @@ export async function signIn(): Promise<void> {
77
103
  }
78
104
 
79
105
  // On /callback or application bootstrap:
80
- export async function bootstrap(): Promise<void> {
81
- const url = new URL(location.href);
82
- const code = url.searchParams.get('code');
83
- if (code !== null) {
84
- url.searchParams.delete('code');
85
- history.replaceState(null, '', url.href);
86
- await session.exchange(code);
87
- } else {
88
- await session.refresh(); // Existing persistent key only; no Authorization/ath.
106
+ export async function bootstrap(): Promise<'ready' | 'sign-in'> {
107
+ try {
108
+ const url = new URL(location.href);
109
+ const code = url.searchParams.get('code');
110
+ if (code !== null) {
111
+ url.searchParams.delete('code');
112
+ history.replaceState(null, '', url.href);
113
+ await session.exchange(code);
114
+ } else {
115
+ await session.refresh(); // Existing persistent key only; no Authorization/ath.
116
+ }
117
+ return 'ready';
118
+ } catch (error) {
119
+ if (error instanceof OidcVaultDpopClientError && error.requiresLogin) return 'sign-in';
120
+ throw error; // Keep recovery state on transient/proof/nonce failures.
89
121
  }
90
122
  }
91
123
 
@@ -109,6 +141,13 @@ JWT expiry never blocks `refresh`. All vault and API credential responses are
109
141
  `Cache-Control: no-store`; body handles are tab-local (see
110
142
  [Storage](#storage)).
111
143
 
144
+ On a new tab with no usable login, bootstrap returns `'sign-in'`; show the
145
+ sign-in action. On success it has accepted the response contract, including
146
+ valid zero-lifetime credentials ([bound profile](#bound-credential-response)).
147
+ Render a recoverable error for other failures rather than clearing auth or
148
+ automatically repeating mutations. A corrupt persisted key needs the explicit
149
+ [reset-then-login action](#explicit-recovery-from-a-corrupt-key).
150
+
112
151
  ## Quickstart: cookie transport
113
152
 
114
153
  Same session shape, `sessionTransport: 'cookie'`, and the matching cookie
@@ -117,11 +156,10 @@ mount. Cookie mode additionally requires **Web Locks** and
117
156
  `DPOP_TAB_COORDINATION_UNSUPPORTED`.
118
157
 
119
158
  ```ts
120
- import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
159
+ import { createOidcVaultDpopSession } from '@web-ts-toolkit/oidc-vault-dpop-client';
121
160
 
122
- const backendOrigin = 'https://api.example.com';
123
- const session = createOidcVaultDpopSession({
124
- backendOrigin,
161
+ export const session = createOidcVaultDpopSession({
162
+ backendOrigin: 'https://api.example.com',
125
163
  basePath: '/auth/oidc/cookie',
126
164
  sessionTransport: 'cookie',
127
165
  });
@@ -129,45 +167,9 @@ const session = createOidcVaultDpopSession({
129
167
  export async function signIn(): Promise<void> {
130
168
  await session.login('/callback?transport=cookie');
131
169
  }
132
-
133
- export async function bootstrap(): Promise<void> {
134
- const url = new URL(location.href);
135
- const code = url.searchParams.get('code');
136
- if (code !== null) {
137
- url.searchParams.delete('code');
138
- history.replaceState(null, '', url.href);
139
- await session.exchange(code);
140
- } else {
141
- await session.refresh(); // Single-flight; winner token shared over BroadcastChannel.
142
- }
143
- }
144
-
145
- export async function getProfile(): Promise<unknown> {
146
- const response = await fetchWithDpop(
147
- { session, apis: [{ origin: backendOrigin, replayNamespace: 'my-app-api' }] },
148
- `${backendOrigin}/api/profile`,
149
- );
150
- if (!response.ok) throw new Error('Protected API request failed.');
151
- return response.json();
152
- }
153
-
154
- export async function saveResource(value: string): Promise<unknown> {
155
- // Mutations are single-attempt unless YOUR operation owns a server-enforced
156
- // idempotency contract (see Retry policy).
157
- const response = await fetchWithDpop(
158
- { session, apis: [{ origin: backendOrigin, replayNamespace: 'my-app-api' }] },
159
- `${backendOrigin}/api/resource`,
160
- { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: value, retry: 'idempotent' },
161
- );
162
- if (!response.ok) throw new Error('Protected API request failed.');
163
- return response.json();
164
- }
165
-
166
- export async function signOut(): Promise<void> {
167
- await session.logout();
168
- }
169
170
  ```
170
171
 
172
+ Apply the callback/bootstrap, API and logout flow above to this session.
171
173
  Cookie refresh uses one same-origin Web Lock per vault scope plus
172
174
  `BroadcastChannel` winner-token delivery: a tab adopts a live peer token for up
173
175
  to 180ms, otherwise it performs its own serialized refresh with the current
@@ -177,17 +179,157 @@ fresh proof. Web Locks coordinate tabs of one frontend origin, storage
177
179
  partition, and vault scope — they are not a backend refresh lease, so other
178
180
  frontends, devices, or processes can still race upstream refresh families.
179
181
 
182
+ ## Lifecycle concurrency and cleanup
183
+
184
+ Use one session instance per tab and vault mount. In **both transports**,
185
+ `login`, `exchange`, `refresh`, and `logout` run in invocation order. A logout
186
+ queued behind refresh uses the rotated handle/cookie. Cookie operations also
187
+ hold the scope's Web Lock across their HTTP work and credential publication.
188
+ Body queues belong to the instance; they do not coordinate other instances or
189
+ copied tab handles.
190
+
191
+ Concurrent `refresh()` callers receive the **same Promise** until a call to
192
+ `login`, `exchange`, `logout`, `clear`, or `resetKey` starts a new batch. The first caller's
193
+ `rejectedToken` applies to that batch. A refresh invoked after a queued logout
194
+ waits behind it instead of joining the earlier refresh. Read-only `getKey()`
195
+ checks can run alongside the queue and one another; only their still-current
196
+ failure cleanup enters the mutation queue. They neither start refresh nor
197
+ erase a pending login merely because exchange has not finished.
198
+
199
+ Every asynchronous session operation keeps its entry-time authority. `clear`,
200
+ `resetKey`, a terminal auth failure, and `dispose` supersede older work, including queued
201
+ operations. Stale results cannot publish tokens/handles, change cookie
202
+ generation metadata, clear a newer login, or navigate. A stale call rejects
203
+ with **`DPOP_OPERATION_SUPERSEDED`**, or **`DPOP_SESSION_DISPOSED`** after
204
+ disposal; both have `requiresLogin: false`. Follow the current session flow
205
+ rather than clearing auth or starting another login in response to these
206
+ errors. Key reads and background peer callbacks also lose authority when a
207
+ newer auth state is published. A new `login()` intent additionally supersedes
208
+ older peer callbacks immediately, including while that login is waiting in the
209
+ lifecycle queue or for the cookie Web Lock.
210
+
211
+ | Method | Effect |
212
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
213
+ | `logout()` | Ordered server logout, then frontend cleanup. Revokes vault refresh state. |
214
+ | `clear()` | Immediately drops this context's memory token, vault nonces, body handle and pending-login marker, and aborts its in-flight vault HTTP. Keeps the persistent key. In cookie mode, its Promise waits for the Web Lock, inactive-generation write and peer notification. It does not call the backend or remove the HttpOnly cookie. |
215
+ | `resetKey()` | Explicit local key recovery. Immediately invalidates the same local work/auth as `clear`, then atomically deletes **this scope's key and cookie-session metadata**. Cookie mode uses the shared Web Lock and notifies affected peers. Repeating on absent records is harmless. Creates no key and makes no backend request; await it, then invoke `login()` explicitly. |
216
+ | `dispose()` | Immediately closes this context, drops its memory token/vault nonces, aborts owned vault HTTP, settles peer waits, removes listeners/timers and closes its channel. Keeps the previously persisted key, handle, pending-login marker and committed cookie metadata so a replacement context can bootstrap normally. Later calls reject. |
217
+
218
+ Clear/reset/dispose invalidate pending cookie-version and session-owned key
219
+ transactions as well as memory publication. After clear or reset, unsolicited peer messages cannot restore this
220
+ context; an explicit successful exchange/refresh must reestablish auth. Peer
221
+ waits end within 180ms, or immediately on local invalidation/disposal, and a
222
+ late peer lookup cannot publish after that wait has ended. Same-origin tabs
223
+ still reuse the current generation's live winner token; key-loss, expiry and
224
+ recognition checks remain mandatory. Reset notifications are advisory: peers
225
+ check that **both** scoped records are still absent and that their local
226
+ key/generation matches the deletion. A delayed notification or old-binding
227
+ failure cannot clear a replacement login, including a fresh key created before
228
+ cookie metadata is written. Concurrent resets share their pending removal until
229
+ another lifecycle mutation intervenes; repeating after completion also succeeds
230
+ when both records are already absent.
231
+
232
+ Clear/token/reset messages are advisory until their shared key/generation state
233
+ is checked. A failed peer recognition or coordination lookup leaves local auth
234
+ and pending login intact. An admitted token's key-validation failure still
235
+ invalidates a matching current local credential; explicit credential operations
236
+ continue to report genuine key/storage and terminal session errors.
237
+
238
+ **Limits:** already-started storage/crypto and noncooperative custom transport
239
+ work may still finish, but superseded results cannot publish. Vault HTTP waits
240
+ are bounded as described below; storage, recognition hooks, and time waiting
241
+ for other Web Lock owners are outside that HTTP deadline. Cleanup cannot undo
242
+ a server mutation or a browser-applied `Set-Cookie`. Await `clear()` or
243
+ `resetKey()` before disposing when its persistence work must finish. Lifecycle cleanup also
244
+ does not cancel caller-owned API requests or revoke already-issued stateless
245
+ JWTs. Coordination remains limited to one frontend origin/storage
246
+ partition/scope.
247
+
248
+ ## HTTP bounds and cancellation
249
+
250
+ All vault `login`/`exchange`/`refresh`/`logout` attempts use these session options:
251
+
252
+ | Option | Default | Valid values / scope |
253
+ | ------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
254
+ | `requestTimeoutMs` | **15,000 milliseconds** | Integer from 1 through 2,147,483,647. One deadline from fetch invocation through response headers, full body read, and JSON parsing. |
255
+ | `maxResponseBytes` | **1,048,576 bytes (1 MiB)** | Positive safe integer. Counts bytes actually read from the fetch body stream on every HTTP status, after browser HTTP decompression. |
256
+
257
+ Invalid options throw `TypeError` at session construction; zero and infinity
258
+ do not disable the bounds. Content-Length is not trusted to measure the body:
259
+ missing, understated, overstated, or malformed values do not change the byte
260
+ count. UTF-8 characters count by their bytes, not JavaScript string length.
261
+ Custom fetch implementations must expose a standard streaming `Response.body`.
262
+
263
+ ```ts
264
+ import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
265
+ import type { OidcVaultDpopSessionOptions } from '@web-ts-toolkit/oidc-vault-dpop-client';
266
+
267
+ const options: OidcVaultDpopSessionOptions = {
268
+ backendOrigin: 'https://api.example.com',
269
+ requestTimeoutMs: 15_000,
270
+ maxResponseBytes: 1_048_576,
271
+ };
272
+ const session = createOidcVaultDpopSession(options);
273
+
274
+ // Supply the caller's AbortController.signal (for example, on page teardown).
275
+ export async function loadProfile(signal: AbortSignal): Promise<unknown> {
276
+ const response = await fetchWithDpop(
277
+ { session, apis: [{ origin: options.backendOrigin, replayNamespace: 'my-app-api' }] },
278
+ `${options.backendOrigin}/api/profile`,
279
+ { signal },
280
+ );
281
+ return response.json();
282
+ }
283
+ ```
284
+
285
+ The HTTP deadline excludes the lifecycle queue, key/proof/recognition work and
286
+ credential publication. An allowed nonce retry gets its own deadline, with
287
+ the existing two-attempt vault budget. A trickling body cannot reset it.
288
+ Deadline or byte overflow rejects with fixed **`DPOP_REQUEST_TIMEOUT`** or
289
+ **`DPOP_RESPONSE_TOO_LARGE`**, both `requiresLogin: false`. These failures do
290
+ not themselves clear auth, and release refresh/FIFO/Web Lock ownership for a
291
+ later explicit operation. Malformed JSON still follows the existing route
292
+ response-contract error handling.
293
+
294
+ Clear/reset/invalidation/disposal abort the session-owned request and stop waiting
295
+ even if an injected fetch or stream ignores AbortSignal. Cancellation and
296
+ reader cleanup are best effort: their failure or noncompletion cannot delay
297
+ settlement or replace its primary outcome. Late responses are discarded and
298
+ late rejections are observed. Timers cannot run in suspended tabs or preempt
299
+ synchronous JavaScript; elapsed time is also checked after decoding/parsing
300
+ before accepting a result.
301
+
302
+ For `fetchWithDpop`, a pre-aborted signal starts no refresh, key lookup, proof
303
+ or network work. Later cancellation rejects with `signal.reason` promptly,
304
+ including while waiting for shared refresh/key work. It cancels only this
305
+ caller's wait; another waiter's shared refresh continues and may publish its
306
+ result. Proof work checks cancellation before signing, including after the
307
+ access-token digest; already-started Web Crypto may finish. Standalone
308
+ `createDpopProof` also accepts `DpopProofInput.signal`. API requests receive the
309
+ caller signal; the returned response/body belongs to the caller. The vault
310
+ timeout/byte options do not bound API bodies. Cleanup of a discarded API
311
+ challenge response cannot stall its permitted retry or replace its outcome.
312
+
313
+ **Uncertain commit / retry:** no timeout, cancellation, byte-limit, or network
314
+ failure automatically retries a mutation. The server may already have
315
+ consumed an exchange code, rotated a refresh handle, or completed logout, and
316
+ the browser may already have applied `Set-Cookie`. Aborting does not roll that
317
+ back. An explicit retry is not evidence that the first attempt failed; a lost
318
+ body-mode rotation can make the retained old handle unusable and require fresh
319
+ login. Use the operation's server-side recovery/idempotency contract when
320
+ deciding what to do next.
321
+
180
322
  ## Backend pairing
181
323
 
182
324
  Configure the vault mount this client calls. `backendOrigin` must be the exact
183
- public origin (scheme + host + port); `basePath` must be the exact public
184
- mount path — trailing slashes are normalized, anything else must match
185
- character-for-character, because the key scope, proof `htu`, and request URL
186
- all derive from it. `use 127.0.0.1` consistently in loopback development:
325
+ public origin (scheme + host + port); `basePath` must match the public mount.
326
+ Unreserved path escapes, dot segments and trailing slashes normalize; the key
327
+ scope, proof `htu` and request URL all derive from that path. Use `127.0.0.1`
328
+ consistently in loopback development:
187
329
  `localhost` is a different origin and a different key scope.
188
330
 
189
331
  Backend options that must agree with this client (authoritative wire contract:
190
- [`express-oidc-vault` README](../express-oidc-vault/README.md)):
332
+ [`express-oidc-vault` README](https://github.com/egose/web-ts-toolkit/blob/main/packages/express-oidc-vault/README.md)):
191
333
 
192
334
  - `basePath`: the same mount (e.g. `/auth/oidc/body` with `sessionTransport:
193
335
  'body'`, `/auth/oidc/cookie` with `'cookie'`).
@@ -204,6 +346,41 @@ Backend options that must agree with this client (authoritative wire contract:
204
346
  - The API's `replayNamespace` must equal the `replayNamespace` configured in
205
347
  `fetchWithDpop` `apis` entries.
206
348
 
349
+ ### Bound credential response
350
+
351
+ Exchange/refresh must return exact `tokenType: 'DPoP'` and a compact signed
352
+ JWT: three nonempty canonical unpadded base64url segments, a nonempty signing
353
+ `alg` other than `none`, and no `b64: false` payload. Its `cnf` must be exactly
354
+ `{ jkt }`, with the canonical RFC 7638 SHA-256 thumbprint of the persistent
355
+ browser key (43 base64url characters, including canonical final bits).
356
+ Additional confirmation fields/methods are rejected.
357
+
358
+ JWT `exp` is mandatory: a nonnegative safe integer in epoch **seconds**, with
359
+ safe `exp * 1000` milliseconds. Response `expiresIn` is nonnegative
360
+ safe-integer **seconds**; its millisecond conversion and addition to the
361
+ current nonnegative safe-integer epoch-millisecond clock must also be safe.
362
+ Invalid or overflowing values are rejected even when a shorter deadline could
363
+ otherwise hide them. The effective `DpopAccessToken.expiresAt` is the earlier
364
+ of the response deadline and JWT expiry. These are **response-contract decode
365
+ checks, not JWT authentication**: each API must independently verify the JWT
366
+ signature/expiry/issuer/audience and the original-key request proof.
367
+
368
+ `expiresIn: 0` remains valid so a rotated body handle can be retained. The
369
+ returned credential may already be expired; `getAccessToken()` is a snapshot,
370
+ not a guarantee of a live token. `fetchWithDpop` checks the deadline per attempt
371
+ and after asynchronous proof work. A nonlive refresh result stops with
372
+ `DPOP_ACCESS_TOKEN_EXPIRED` (`requiresLogin: false`), preserves session handles,
373
+ and starts no additional refresh loop; a later explicit refresh may recover.
374
+ Cookie peer adoption clamps the envelope deadline to JWT expiry and requires
375
+ the final result to be strictly in the future. Cookie responses and peer
376
+ tokens must contain no `sessionId` property.
377
+
378
+ Invalid direct responses produce `INVALID_BOUND_CREDENTIAL_RESPONSE`; invalid
379
+ or expired peer tokens are ignored. The paired server enforces the same bound
380
+ JWT profile before claim mapping in **both** validator methods after JOSE
381
+ verification. Generic unbound JWTs retain optional-expiry compatibility, and
382
+ unbound issuers can still return opaque tokens; this client expects bound JWTs.
383
+
207
384
  ### CORS
208
385
 
209
386
  The backend must answer credentialed cross-origin browser calls:
@@ -224,13 +401,64 @@ Vault Origin/Referer checks run independently of CORS. Cookie-authenticated
224
401
  When nonces are enabled, a missing/expired nonce returns fixed
225
402
  `OIDC_VAULT_USE_DPOP_NONCE`: **HTTP 400 at vault POSTs**
226
403
  (login/exchange/refresh/logout), **HTTP 401 with `DPoP error="use_dpop_nonce"`
227
- at protected APIs**. Both helpers retry **once** with a fresh
228
- proof/`jti`/`iat`/signature/`ath`, then stop: repeated challenges throw
229
- (`DPOP_NONCE_RETRY_EXHAUSTED` on vault POSTs; the API response is returned to
230
- the caller). Nonces are cached in memory per protection space and key; vault
231
- and API spaces are separate. Only `DPoP error="invalid_token"` triggers an API
232
- refresh (at most one refresh/retry cycle); generic 401s, proof errors, 403s,
233
- 503s, and network failures never do.
404
+ at protected APIs**.
405
+
406
+ - Vault POSTs allow **one nonce retry** when that code arrives on HTTP 400 or
407
+ 401 with a valid `DPoP-Nonce`. A second nonce challenge throws
408
+ **`DPOP_NONCE_RETRY_EXHAUSTED`**, `requiresLogin: false`, with its HTTP status.
409
+ Existing auth/pending-login state is retained for explicit recovery; starting
410
+ a new `login()` still performs its documented frontend reset. A first
411
+ challenge with no usable nonce returns the fixed non-terminal
412
+ `OIDC_VAULT_USE_DPOP_NONCE` error without a retry.
413
+ - API calls allow one nonce retry **only when their retry policy permits it**
414
+ (see [Retry policy](#retry-policy-fetchwithdpop)). Only an HTTP-401 DPoP
415
+ `error="invalid_token"` challenge allows `session.refresh()` and a further
416
+ attempt, at most once per invocation. An initial refresh for absent/expired
417
+ credentials uses that same refresh budget. There are at most three API HTTP
418
+ attempts: the original plus one nonce retry and one refresh retry. The final
419
+ API response, including a repeated challenge, is returned to the caller.
420
+
421
+ Each permitted attempt mints a fresh proof/`jti`/signature with the current
422
+ `iat` and API-only `ath`; vault proofs never carry `ath`. Replayed bodies keep
423
+ their original snapshot. Generic 401s, proof errors, 403s, 503s, and network
424
+ failures do not trigger API refresh or extra attempts.
425
+
426
+ **Learning is independent of retry permission.** Both helpers remember a
427
+ valid nonce from the selected trusted response, including success and final
428
+ error responses. An unsafe POST still makes exactly one attempt and returns
429
+ its challenge; the **next explicit invocation** can send the learned nonce.
430
+ Learning from a header never authorizes resubmission of the current mutation.
431
+
432
+ ### Nonce cache and challenge parsing
433
+
434
+ `DpopNonceCache` holds at most **64 `(protection space, key thumbprint)`
435
+ tuples** per instance. A successful `remember` renews that tuple's write order;
436
+ insertion at capacity evicts the **oldest remembered** tuple. Reads do not
437
+ promote entries, and rejected values neither replace an entry nor change
438
+ eviction order. Vault mounts and API `(origin, replayNamespace)` spaces remain
439
+ separate. API calls share a default cache through their session, even when a
440
+ new context wrapper is passed each time; a supplied `nonces` cache uses the
441
+ same limits and isolation.
442
+
443
+ Accepted nonce headers contain **one opaque value of 1–512 visible ASCII
444
+ characters**, excluding whitespace, double quotes, backslashes, and commas.
445
+ The client never trims, splits, unquotes, or decodes the exposed header value.
446
+ Comma-containing values, including Fetch-coalesced duplicate fields (even
447
+ identical ones), are rejected without overwriting a usable nonce. This is the
448
+ OAuth nonce-character syntax restricted to an unambiguous single header value.
449
+ There is **no client-invented TTL**: nonce expiry is determined by the server's
450
+ next challenge, not by interpreting its private nonce format. Cache eviction
451
+ can likewise require a new challenge.
452
+
453
+ The API `WWW-Authenticate` parser accepts auth-parameters in any order, token
454
+ or quoted values, and other authentication schemes in the same field. Only
455
+ **one unambiguous DPoP challenge** can trigger recovery. Bearer errors and
456
+ quoted descriptions never supply a DPoP error. Duplicate parameter names
457
+ (case-insensitive), multiple DPoP challenges, malformed/empty list members,
458
+ or a field longer than **8,192 characters** cause no refresh/retry. A valid
459
+ nonce header can still be learned independently of that decision. Vault
460
+ status/nonce fields are captured before body reads, so an injected response
461
+ cannot change the recovery decision while its body is awaited.
234
462
 
235
463
  ## Storage
236
464
 
@@ -248,6 +476,10 @@ opener-cloned `sessionStorage` is not a shared-refresh strategy. No tokens or
248
476
  handles enter `localStorage` or IndexedDB. Crypto runs outside the readwrite
249
477
  transaction; export of the private key always fails by construction.
250
478
 
479
+ The historical database name `oidc-vault-dpop-example-v1` and version-1 schema
480
+ are retained so installed keys survive upgrades. Use `resetKey()` for scoped
481
+ recovery rather than deleting the entire origin's database.
482
+
251
483
  ## Key loss and errors
252
484
 
253
485
  Key loss or key change clears frontend auth and requires a fresh login —
@@ -255,14 +487,91 @@ Key loss or key change clears frontend auth and requires a fresh login —
255
487
  missing key; exchange/refresh/API/logout read the existing key and compare its
256
488
  binding (`cnf.jkt` decode check plus key-equality check). There is no Bearer
257
489
  fallback and no ephemeral-key fallback. Storage/crypto failures stop the
258
- operation; clear frontend auth and request fresh login.
490
+ operation; error handling never silently deletes or creates a key.
491
+
492
+ ### Explicit recovery from a corrupt key
493
+
494
+ An **existing but unusable** record (for example, a mismatched private/public
495
+ pair) also fails validation during `login()`. Retrying login or calling
496
+ `clear()` cannot repair it: clear deliberately keeps the key. Offer an explicit
497
+ recovery action that calls `resetKey()` and awaits it before starting login:
498
+
499
+ ```ts
500
+ import { createOidcVaultDpopSession } from '@web-ts-toolkit/oidc-vault-dpop-client';
501
+
502
+ const session = createOidcVaultDpopSession({
503
+ backendOrigin: 'https://api.example.com',
504
+ basePath: '/auth/oidc/body',
505
+ sessionTransport: 'body',
506
+ });
259
507
 
260
- `OidcVaultDpopClientError` carries a stable `code`, a fixed message (never
508
+ // A user-invoked recovery button when the persisted browser key is unusable.
509
+ export async function recoverBrowserKey(): Promise<void> {
510
+ await session.resetKey();
511
+ await session.login('/callback?transport=body');
512
+ // The redirect callback calls session.exchange(code), as in the quickstart.
513
+ }
514
+ ```
515
+
516
+ Reset bypasses key validation and removes both scoped IndexedDB records in one
517
+ guarded transaction. It leaves other vault mounts/backend origins and their
518
+ installed keys/metadata intact. The next explicit login persists a fresh
519
+ non-extractable key using the same atomic first-key-wins path across tabs.
520
+ IndexedDB still has to be available; reset does not repair denied/unavailable
521
+ browser storage. Recovery is never automatic on API, refresh, logout, proof,
522
+ nonce, timeout, or recognition errors.
523
+
524
+ **Reset is local-only recovery.** Other sessions using the removed key need a
525
+ fresh login; cookie peers are notified under the shared lock, and body tabs
526
+ detect the lost/changed key on their next credential operation. The HttpOnly
527
+ cookie is not removed, old server sessions remain until **explicit revocation
528
+ or expiry**, and issued stateless JWTs are not revoked. If the key is still
529
+ usable and server sign-out is wanted, call `logout()` before resetting it.
530
+ An already-started API request, browser-applied cookie, server mutation, or
531
+ previously obtained `CryptoKey` reference cannot be undone by local deletion.
532
+ API requests remain caller-owned; use their AbortSignal for local cancellation.
533
+
534
+ ### Error classification
535
+
536
+ Package-generated `OidcVaultDpopClientError`s carry a stable `code`, a fixed message (never
261
537
  keys, credentials, fingerprints, or URLs), `requiresLogin`, and an optional
262
538
  HTTP `status`. Treat `requiresLogin === true` as "route to sign-in"; enrolled
263
539
  recognition changes surface as `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` (clear
264
540
  auth, start fresh POST login, no automatic re-enrollment).
265
541
 
542
+ Vault JSON errors use an explicit code allowlist rather than treating every
543
+ HTTP 401 as logout. These **terminal** server codes clear local auth and
544
+ pending login, invalidate cookie coordination metadata, and require sign-in:
545
+
546
+ | Code | Meaning |
547
+ | ---------------------------------------- | --------------------------------------------------------------------- |
548
+ | `OIDC_VAULT_INVALID_SESSION` | Session is missing, expired, or no longer usable. |
549
+ | `OIDC_VAULT_MISSING_SESSION_ID` | The body handle or session cookie is missing (HTTP 400). |
550
+ | `OIDC_VAULT_MALFORMED_SESSION_COOKIE` | The session cookie is malformed (HTTP 400). |
551
+ | `OIDC_VAULT_INVALID_EXCHANGE_CODE` | The exchange code is invalid/expired (HTTP 400); begin a fresh login. |
552
+ | `OIDC_VAULT_INVALID_BROWSER_BINDING` | The login transaction's browser-cookie binding failed. |
553
+ | `OIDC_VAULT_DEVICE_BINDING_REQUIRED` | The session needs a device-bound login. |
554
+ | `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` | Enrolled recognition changed or is absent. |
555
+
556
+ Proof/nonce errors (`OIDC_VAULT_INVALID_DPOP_PROOF`,
557
+ `OIDC_VAULT_DPOP_REQUIRED`, `OIDC_VAULT_USE_DPOP_NONCE`) are **non-terminal**,
558
+ even on HTTP 401. Replay-store unavailability, upstream/provider failures, and
559
+ request/configuration failures also retain auth. An invalid recognition input
560
+ (`OIDC_VAULT_INVALID_FINGERPRINT`) is non-terminal; it differs from a confirmed
561
+ enrollment mismatch. Non-terminal does not promise that a retry will succeed
562
+ or authorize one automatically; use the operation's recovery contract.
563
+
564
+ Unknown codes, including object-prototype names, become fixed
565
+ **`OIDC_REQUEST_FAILED` / `The OIDC request failed.`**, `requiresLogin: false`;
566
+ server codes/messages are never copied into that fallback. The HTTP status is
567
+ retained. Local key loss, binding/token-type violations and invalid bound
568
+ credential responses remain terminal. API HTTP failures are still caller-owned
569
+ `Response`s, with automatic recovery limited to the challenge rules above.
570
+
571
+ Construction can also throw `TypeError` for invalid options; caller abort
572
+ rejects with `signal.reason`. Do not assume every thrown value is a package
573
+ error or automatically start login for a non-terminal failure.
574
+
266
575
  ## Credentials matrix
267
576
 
268
577
  `credentials` is owned by the helpers; callers must not set it.
@@ -277,12 +586,98 @@ Cookie JSON always omits `sessionId`. Every request uses `redirect: 'error'`
277
586
  and `cache: 'no-store'`. Vault POSTs present no access token; API calls send
278
587
  `Authorization: DPoP <JWT>` plus a fresh same-key proof with `ath`.
279
588
 
589
+ ## Multiple services behind a gateway
590
+
591
+ Use `DpopApi.pathPrefix` to select each service's credential policy and backend
592
+ `replayNamespace` on one public origin. Pass the **full public URL**, including
593
+ the service path; `pathPrefix` selects the scope without adding or stripping a
594
+ mount. Bootstrap the session with login/exchange or refresh as above.
595
+
596
+ ```ts
597
+ import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
598
+ import type { DpopApi, DpopFetchContext } from '@web-ts-toolkit/oidc-vault-dpop-client';
599
+
600
+ const origin = 'https://gateway.example.com';
601
+ const session = createOidcVaultDpopSession({
602
+ backendOrigin: origin,
603
+ basePath: '/auth/oidc/body',
604
+ sessionTransport: 'body',
605
+ });
606
+ const apis: readonly DpopApi[] = [
607
+ { origin, pathPrefix: '/orders', replayNamespace: 'orders-api' },
608
+ // This billing service also uses an application cookie.
609
+ { origin, pathPrefix: '/billing', replayNamespace: 'billing-api', credentials: 'include' },
610
+ ];
611
+ const gateway: DpopFetchContext = { session, apis };
612
+
613
+ export function loadOrders(): Promise<Response> {
614
+ return fetchWithDpop(gateway, `${origin}/orders?limit=20`);
615
+ }
616
+
617
+ export function loadInvoices(): Promise<Response> {
618
+ return fetchWithDpop(gateway, `${origin}/billing/invoices`);
619
+ }
620
+ ```
621
+
622
+ Selection is **case-sensitive at normalized path-segment boundaries**:
623
+ `/orders` matches `/orders`, `/orders/`, and `/orders/42`, but neither
624
+ `/orders-evil` nor `/orders%2Fevil`. The longest matching prefix wins, regardless
625
+ of array order. For example, `/orders/admin` takes precedence over `/orders`;
626
+ `/orders/admin-evil` still uses `/orders`. Trailing prefix slashes are removed,
627
+ so `/orders/` and `/orders` define the same scope.
628
+
629
+ **Omission and root are origin-wide.** Leaving out `pathPrefix` preserves the
630
+ existing whole-origin behavior. Explicit `/`, or a prefix that normalizes to
631
+ `/`, also permits credentials on **every path** at that origin. A root entry is
632
+ a fallback for paths outside more-specific entries. Without such a fallback,
633
+ an unmatched path fails before access-token lookup, refresh, key lookup,
634
+ signing, or network work.
635
+
636
+ Matching, the outbound pathname, and proof `htu` use the same normalization:
637
+ unreserved percent escapes (`A–Z`, `a–z`, digits, `-._~`) decode, remaining
638
+ escape hex digits uppercase, and dot segments collapse. Thus `/ord%65rs` is
639
+ sent as `/orders`, and `/orders/%2e%2e/billing` can only use the billing scope
640
+ (or an origin-wide fallback). **Reserved escapes stay encoded**: `%2F`, `%5C`,
641
+ `%3F`, and `%23` never become separators, a new authority, query, or fragment.
642
+ Double-encoded escapes are not decoded again. Query order, duplicate query
643
+ parameters, escaping, and fragment data are retained in the URL passed to
644
+ fetch. HTTP omits the fragment; the proof omits both query and fragment. The
645
+ same uppercase method is used for the proof and every outbound attempt.
646
+
647
+ Every invocation validates **all** entries, including nonmatching ones, before
648
+ auth work. An origin must contain only scheme/host/port and an optional root
649
+ slash, with HTTPS except for loopback development. A prefix must be an absolute
650
+ path without a URL/`//` authority-like prefix, query, fragment, whitespace,
651
+ backslash, control character, or malformed escape. `replayNamespace` must be
652
+ 1–128 printable ASCII characters; `credentials` accepts only `omit` (default)
653
+ or `include`. Duplicate normalized `(origin, pathPrefix)` scopes are rejected
654
+ even if their policies are identical: this includes omission versus `/`,
655
+ trailing slashes, equivalent unreserved escapes, and equivalent dot segments.
656
+ Targets must already be absolute HTTP(S) URLs; configuring a prefix never
657
+ makes a relative, userinfo-bearing, or malformed target usable.
658
+
659
+ The target and selected origin/path/namespace/**credentials** are snapshotted
660
+ before asynchronous auth work. All attempts and nonce learning for that
661
+ invocation keep that snapshot, including across refresh/key waits. Caller
662
+ configuration changes apply to later invocations. Nonces retain the existing
663
+ `['api', normalizedOrigin, replayNamespace]` + signing-key-thumbprint tuple:
664
+ the two services above use independent nonce spaces, while paths deliberately
665
+ using the same origin/namespace share a nonce space. Path selection leaves the
666
+ nonce/refresh retry budgets and single-attempt mutation policy below intact.
667
+
668
+ | Failure | Fixed client code (`requiresLogin: false`) |
669
+ | ---------------------------------------------------- | ------------------------------------------ |
670
+ | No matching origin/path scope | `DPOP_API_OUT_OF_SCOPE` |
671
+ | Invalid policy fields or duplicate normalized scopes | `INVALID_DPOP_API_CONFIG` |
672
+ | Malformed URL, origin syntax, or prefix | `INVALID_DPOP_TARGET` |
673
+ | Configured non-loopback HTTP origin | `INSECURE_DPOP_ORIGIN` |
674
+
280
675
  ## Retry policy (`fetchWithDpop`)
281
676
 
282
- Scoped `fetch` for exactly the configured API origins: it owns
677
+ Scoped `fetch` for the configured API origin/path subtrees: it owns
283
678
  `Authorization`/`DPoP` (caller-set auth headers throw
284
679
  `DPOP_AUTH_HEADER_OVERRIDE`), mints a fresh proof per attempt, and accepts only
285
- exact origin matches with a valid `replayNamespace`.
680
+ exact origin matches with a matching `pathPrefix` and valid `replayNamespace`.
286
681
 
287
682
  | Method | Default attempts | Opt-in |
288
683
  | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
@@ -373,25 +768,40 @@ singleton, and no client router — the callback is the same page reading
373
768
  ## API reference
374
769
 
375
770
  - `createOidcVaultDpopSession(options)` → `OidcVaultDpopSession`
376
- (`login`/`exchange`/`refresh`/`logout`/`getAccessToken`/`getKey`/`clear`/`dispose`).
771
+ (`login`/`exchange`/`refresh`/`logout`/`getAccessToken`/`getKey`/`clear`/`resetKey`/`dispose`).
377
772
  Options: `backendOrigin`, `basePath` (default `/auth/oidc`),
378
773
  `sessionTransport` (`'body'` default | `'cookie'`), optional `fingerprint`,
774
+ `requestTimeoutMs` (default 15,000 ms), `maxResponseBytes` (default 1 MiB),
379
775
  injectable `fetch`/`navigate`/`now` for tests.
380
776
  - `fetchWithDpop({ session, apis, fetch?, nonces?, now? }, input, options?)` →
381
777
  `Promise<Response>`. `apis: DpopApi[]` pins `{ origin, replayNamespace,
382
- credentials? }`.
778
+ pathPrefix?, credentials? }`; longest normalized segment-prefix match wins.
383
779
  - `getOrCreateDpopKey(scope, { create? })`, `createDpopProof(key, input)`,
384
780
  `DpopNonceCache`, `createDeviceFingerprint(source, { headerName? })`,
385
781
  `fingerprintJsSignalSource(load)`, `OidcVaultDpopClientError`.
386
782
  - Types: `DpopKey`, `DpopKeyScope`, `DpopProofInput`, `DpopAccessToken`,
387
783
  `DpopFetchContext`, `DpopFetchOptions`, `DpopApi`,
388
- `OidcVaultDpopSession(Options)`, `DeviceFingerprint(Options)`,
784
+ `OidcVaultDpopSession`, `OidcVaultDpopSessionOptions`, `DeviceFingerprint`, `DeviceFingerprintOptions`,
389
785
  `DeviceFingerprintSignalSource`, `FingerprintJsAgent`. Wire DTOs stay
390
- single-sourced from `@web-ts-toolkit/express-oidc-vault` as types.
786
+ single-sourced from the pure `@web-ts-toolkit/express-oidc-vault/wire` entry
787
+ at build time; no backend import survives in client declarations/JavaScript.
788
+
789
+ ## Upgrade notes
790
+
791
+ | Area | Consumer action / delivered contract |
792
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
793
+ | Node and types | Use **Node >=22.12.0** for CJS `require(esm)`. Browser declarations now need only DOM/ES libs; remove compensating Express/Node ambient types if your app has no other use for them. Backend root type imports and `req.auth` remain available. |
794
+ | Bound JWT profile | Issuers must return canonical compact signed JWTs, exact `{ cnf: { jkt } }`, mandatory safe-integer `exp` seconds and safe millisecond lifetimes. Extra confirmation fields, missing/unsafe expiry or overflowing arithmetic are rejected. The generic server unbound/opaque contracts remain compatible. `expiresIn: 0` preserves rotated handles but supplies no live reusable API/peer token. |
795
+ | Lifecycle ordering | Both transports now use FIFO mutations. Refresh sharing ends at intervening mutations. Failed advisory peer reads preserve auth; older peer callbacks yield to a newer login intent, even while queued. Handle `DPOP_OPERATION_SUPERSEDED` / `DPOP_SESSION_DISPOSED` as non-terminal; follow the newer flow. Await clear/reset before teardown when persistence must finish. |
796
+ | HTTP limits and cancellation | Vault attempts default to **15,000 ms / 1 MiB**, including injected fetches. Configure larger finite budgets when warranted. Injected responses need a byte stream, not only a custom `json()`. API/proof `signal` stops waits and later attempts; discarded-response cleanup cannot replace the outcome. Lost mutation responses remain uncertain commits. |
797
+ | Nonces and retries | Success/final/single-attempt responses now teach the bounded 64-tuple cache. Reordered unambiguous DPoP challenges work; repeated vault challenges give `DPOP_NONCE_RETRY_EXHAUSTED`. Learning a nonce never authorizes a blanket mutation retry. |
798
+ | Error handling | Use known `code` / `requiresLogin`, not every 401. Proof/nonce/transient errors retain auth; terminal session/exchange errors include HTTP 400. Unknown server codes become fixed `OIDC_REQUEST_FAILED`. |
799
+ | Gateway scopes | Existing origin-only entries remain origin-wide. Add `pathPrefix` to restrict forwarding; replace duplicate normalized scopes with distinct prefixes or one shared policy. Outbound pathname now follows proof normalization, including unreserved escapes; query data is preserved. Origin fields reject paths or query/fragment markers even when parsing would erase them (`/orders/..`, `?`, `#`). |
800
+ | Key recovery | `OidcVaultDpopSession` includes **`resetKey(): Promise<void>`**; add it to typed mocks. Use explicit reset-then-login for unusable records. No DB rename/migration or implicit replacement occurs. Reset is local recovery, with old sessions/JWTs remaining until revocation/expiry. |
391
801
 
392
802
  Worked end-to-end example (Vite SPA + Express vault/API + local OIDC
393
803
  provider, both transports, real-browser evidence):
394
- [`apps/oidc-vault-dpop-example`](../../apps/oidc-vault-dpop-example/README.md).
804
+ [`apps/oidc-vault-dpop-example`](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md).
395
805
  Migrating from the pre-publish copy: replace `copy src/auth/` + `pnpm add
396
806
  jose idb` with `pnpm add @web-ts-toolkit/oidc-vault-dpop-client`, rewrite
397
807
  `from './auth…'` imports to the package root, and keep `basePath` on the exact