@web-ts-toolkit/express-oidc-vault 0.43.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/README.md +1217 -88
  2. package/index.d.mts +697 -15
  3. package/index.d.ts +697 -15
  4. package/index.js +2494 -487
  5. package/index.mjs +2503 -497
  6. package/package.json +6 -4
package/README.md CHANGED
@@ -10,11 +10,14 @@ Current implementation includes:
10
10
 
11
11
  - the core middleware factory
12
12
  - OIDC login redirect with PKCE, `state`, and `nonce`
13
+ - opt-in JSON POST login with an HttpOnly transaction cookie and verified/reserved proof when DPoP is enabled
13
14
  - callback token exchange, server-side session creation, and one-time local exchange codes
14
15
  - session refresh with session ID rotation
15
16
  - server-driven upstream logout redirect using stored `id_token`
16
17
  - OIDC backchannel logout handling via `logout_token`
17
18
  - public TypeScript interfaces for hooks, sessions, config helpers, and store providers
19
+ - request-aware local JWT API authentication with opt-in DPoP proof, nonce, and shared replay enforcement
20
+ - separate opt-in generic fingerprint recognition at POST login and before exchange/refresh (change detection, not PoP)
18
21
 
19
22
  ## Installation
20
23
 
@@ -22,6 +25,15 @@ Current implementation includes:
22
25
  pnpm add @web-ts-toolkit/express-oidc-vault express
23
26
  ```
24
27
 
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
+
30
+ Use **named imports from the package root**. There is no default export or public subpath API.
31
+
32
+ ## Requirements
33
+
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
+ - 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
+
25
37
  ## Frontend Storage Policy
26
38
 
27
39
  Default browser-side transport:
@@ -61,7 +73,7 @@ This is the default mode.
61
73
 
62
74
  This mode stores `sessionId` in a backend-managed cookie.
63
75
 
64
- - `exchange` sets the session cookie and does not need to return `sessionId` in the JSON body
76
+ - `exchange` sets the session cookie and omits `sessionId` from the JSON body
65
77
  - `refresh` reads the cookie, rotates the session, and updates the cookie
66
78
  - `logout` reads the cookie and clears it
67
79
  - `refresh` and `logout` require the cookie and reject body-only `sessionId` values
@@ -77,9 +89,9 @@ Available cookie options:
77
89
  - `cookie.secure`
78
90
  - `cookie.domain`
79
91
  - `cookie.path`
80
- - `trustedOrigins`: browser origins allowed to call cookie-authenticated `refresh` and `logout`; required when cross-site cookie transport is enabled
92
+ - `trustedOrigins`: browser sources for POST login/guarded exchange in **both** transports and cookie-authenticated `refresh`/`logout`; required when cross-site session cookie transport is enabled
81
93
 
82
- `cookie.httpOnly` is always enforced as `true`. Middleware creation rejects `httpOnly: false` and unsafe cookie names, domains, or paths so untrusted values cannot be serialized into `Set-Cookie` headers.
94
+ `cookie.httpOnly` is always enforced as `true`. Middleware creation rejects `httpOnly: false` and unsafe cookie names, domains, or paths so untrusted values cannot be serialized into `Set-Cookie` headers. `__Secure-` names require an effectively `Secure` cookie; `__Host-` names additionally require no `cookie.domain` and `cookie.path: '/'`.
83
95
 
84
96
  Default cookie behavior:
85
97
 
@@ -88,10 +100,13 @@ Default cookie behavior:
88
100
  - `httpOnly`: `true`
89
101
  - `deploymentMode`: `same-origin`
90
102
  - `sameSite`: `lax` unless `deploymentMode` is `cross-site`
91
- - `secure`: `true` when `sameSite` resolves to `none` or `deploymentMode` is `cross-site`
103
+ - `secure`: `true` for HTTPS `backendOrigin`, `sameSite: 'none'`, or `deploymentMode: 'cross-site'`; otherwise `false` as an intentional HTTP local-development policy (set `secure: true` explicitly when terminating TLS upstream of an `http` origin, or `secure: false` explicitly to opt out on HTTPS)
104
+ - `SameSite=None` is always serialized with `Secure` because browsers reject `SameSite=None` without it, even with explicit `secure: false`
92
105
 
93
106
  Cookie-authenticated `refresh` and `logout` requests use a fail-closed CSRF policy for every `SameSite` mode. The request must include an `Origin` header, or a valid `Referer` header, whose origin matches `backendOrigin` or one of the configured `trustedOrigins`. Requests with no source-origin header are rejected. Backchannel logout is not affected because it is authenticated with the signed OIDC logout token rather than the browser session cookie.
94
107
 
108
+ **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
+
95
110
  Recommended frontend boot flow:
96
111
 
97
112
  1. Read `sessionId` from `sessionStorage`.
@@ -111,13 +126,14 @@ If you use `sessionTransport: 'cookie'`, the frontend boot flow becomes simpler:
111
126
  The core middleware exposes these endpoints under a configurable base path:
112
127
 
113
128
  - `GET /auth/oidc/login`
129
+ - `POST /auth/oidc/login` when `deviceBinding` or `fingerprintRecognition` is configured (JSON initiation; see below)
114
130
  - `GET /auth/oidc/callback`
115
131
  - `POST /auth/oidc/exchange`
116
132
  - `POST /auth/oidc/refresh`
117
133
  - `POST /auth/oidc/logout`
118
134
  - `POST /auth/oidc/backchannel-logout`
119
135
 
120
- The mounted OIDC router parses JSON and `application/x-www-form-urlencoded` request bodies with an explicit default limit of `16kb`. This covers the small route payloads used by `exchange`, `refresh`, `logout`, and form-encoded backchannel logout. If an IdP requires a larger `logout_token`, set `requestBodyLimit` to a string or byte count accepted by Express body parsers.
136
+ The mounted OIDC router parses JSON and `application/x-www-form-urlencoded` request bodies with an explicit default limit of `16kb`. This covers the small route payloads used by `exchange`, `refresh`, `logout`, and form-encoded backchannel logout. POST login accepts only `application/json`; other media types return `415 OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE` before the general parsers run. If an IdP requires a larger `logout_token`, set `requestBodyLimit` to a string or byte count accepted by Express body parsers.
121
137
 
122
138
  Parser failures return a JSON client error before route handlers or store/provider hooks run. The stable error codes are:
123
139
 
@@ -127,7 +143,7 @@ Parser failures return a JSON client error before route handlers or store/provid
127
143
  - `OIDC_VAULT_MALFORMED_REQUEST_BODY`
128
144
  - `OIDC_VAULT_INVALID_REQUEST_BODY`
129
145
 
130
- ## Quick Start
146
+ ## Quick Start (default unbound lifecycle)
131
147
 
132
148
  ```ts
133
149
  import express from 'express';
@@ -148,6 +164,7 @@ app.use(
148
164
  },
149
165
  frontendRedirectUri: 'https://frontend.example.com/callback',
150
166
  storeProvider,
167
+ sessionTtlMs: 8 * 60 * 60 * 1000, // Opt in to an eight-hour absolute session lifetime.
151
168
  }),
152
169
  );
153
170
  ```
@@ -158,28 +175,753 @@ Use the memory store for local development and tests. For production deployments
158
175
 
159
176
  ## Public Options And Defaults
160
177
 
161
- | Option | Default | Contract |
162
- | ------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
163
- | `basePath` | `/auth/oidc` | Mount path for the OIDC router. Route paths listed in this README are relative to this value. |
164
- | `backendOrigin` | required | Public backend origin registered with the OIDC provider. Callback redirect URIs are derived from this pinned origin, not request host headers. |
165
- | `storeProvider` | required | Durable vault store provider. Use Redis or MongoDB for production and multi-instance deployments. |
166
- | `config` | env-compatible helper input | Provider config. `issuer` is required for discovery and manual modes so ID and logout tokens are issuer-bound. |
167
- | `frontendRedirectUri` | unset | Default browser return target after backend callback completion. Required if login accepts a custom `returnTo`. |
168
- | `postLogoutRedirectUri` | unset | Optional provider-registered HTTP(S) URL used in the upstream end-session redirect. |
169
- | `fetchUserInfo` | implementation default | When enabled, UserInfo claims are fetched and merged only after the `sub` matches the verified ID token subject. |
170
- | `authorizationTransactionTtlMs` | `600000` | TTL for one-time authorization transactions created during login. |
171
- | `exchangeCodeTtlMs` | `30000` | TTL for one-time local exchange codes returned to the frontend callback route. |
172
- | `sessionTransport` | `body` | `body` returns and accepts JSON `sessionId`; `cookie` stores the session pointer in an `HttpOnly` cookie and rejects body-only refresh/logout IDs. |
173
- | `cookie` | see cookie defaults above | Cookie transport options. `httpOnly` is always enforced as `true`; unsafe names, paths, and domains are rejected. |
174
- | `trustedOrigins` | `[]` plus `backendOrigin` internally | Browser origins allowed to call cookie-authenticated `refresh` and `logout`. Required for cross-site cookie transport. |
175
- | `requestBodyLimit` | `16kb` | Express JSON and URL-encoded parser limit for OIDC route bodies. Increase only for known provider backchannel logout token size needs. |
176
- | `providerRequestTimeoutMs` | `5000` | Timeout for discovery, token, UserInfo, and remote JWKS requests. Must be a positive finite integer. |
177
- | `hooks` | unset | Pre-commit hooks can veto operations by throwing; post-commit notification hook failures are reported to `onError` without undoing committed state. |
178
- | `tokenIssuer` | unset | Issues app-local access tokens for `exchange` and `refresh`. This lifetime is separate from upstream token and vault-session lifetimes. |
178
+ | Option | Default | Contract |
179
+ | ------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
180
+ | `basePath` | `/auth/oidc` | Mount path for the OIDC router. Route paths listed in this README are relative to this value. |
181
+ | `backendOrigin` | required | Public backend origin registered with the OIDC provider. Callback redirect URIs are derived from this pinned origin, not request host headers. |
182
+ | `storeProvider` | required | Durable vault store provider. Use Redis or MongoDB for production and multi-instance deployments. |
183
+ | `config` | required provider values | Supply `issuer` and `clientId`, or use `resolveOidcVaultConfigFromEnv(process.env)`. Endpoint settings select manual mode; see Config Helpers. |
184
+ | `frontendRedirectUri` | unset | Default browser return target after backend callback completion. Required if login accepts a custom `returnTo`. Validated before durable callback state; missing destination fails the callback with `500 OIDC_VAULT_MISSING_FRONTEND_REDIRECT_URI`. |
185
+ | `postLogoutRedirectUri` | unset | Optional provider-registered HTTP(S) URL used in the upstream end-session redirect. Only consulted for redirected logout (`redirect: true`); upstream failures fall back to local `200 { loggedOut: true }` with `onError`. |
186
+ | `fetchUserInfo` | enabled when usable | Fetches UserInfo if an endpoint exists and the token response supplies an access token; false disables it. Claims merge only after matching the verified subject. |
187
+ | `authorizationTransactionTtlMs` | `600000` | TTL for one-time authorization transactions created during login. |
188
+ | `exchangeCodeTtlMs` | `30000` | TTL for one-time local exchange codes returned to the frontend callback route. |
189
+ | `sessionTtlMs` | unset | Opt-in positive safe-integer lifetime in milliseconds from callback session creation. Hooks may shorten it; refresh never extends it. |
190
+ | `sessionTransport` | `body` | `body` returns and accepts JSON `sessionId`; `cookie` stores the session pointer in an `HttpOnly` cookie and rejects body-only refresh/logout IDs. |
191
+ | `cookie` | see cookie defaults above | Cookie transport options. `httpOnly` is always enforced as `true`; unsafe names, paths, domains, and `__Secure-`/`__Host-` prefix violations are rejected. |
192
+ | `deviceBinding` | disabled | Vault DPoP policy; object defaults to `optional`. Selects the initiating key at POST login and enforces it through callback/exchange/refresh/logout. Configure API enforcement separately. |
193
+ | `fingerprintRecognition` | disabled | Separate opt-in recognition; `headerName` defaults to `X-Device-Fingerprint`. POST-only enrollment, precommit exchange/refresh comparison, fresh login on change; never PoP or an API sender constraint. |
194
+ | `transactionCookie` | HTTPS `__Host-oidc_vault_transaction`, Lax | Temporary POST-login browser cookie, independent of session transport; only `name` and `sameSite: 'lax' \| 'none'` are configurable. |
195
+ | `trustedOrigins` | `[]` plus `backendOrigin` internally | POST-login/guarded-exchange source origins in both transports, plus cookie-authenticated `refresh` and `logout`. Required for cross-site session cookie transport. |
196
+ | `requestBodyLimit` | `16kb` | Express JSON and URL-encoded parser limit for OIDC route bodies. Increase only for known provider backchannel logout token size needs. |
197
+ | `providerRequestTimeoutMs` | `5000` | Deadline per provider HTTP exchange (headers plus complete body). Cancellation is attempted without awaiting cleanup. Positive finite integer; validated before cache lookup. |
198
+ | `hooks` | unset | Pre-commit hooks can veto operations by throwing; post-commit notification hook failures are reported to `onError` without undoing committed state. |
199
+ | `tokenIssuer` | unset | Issues app-local access tokens for `exchange` and `refresh`. This lifetime is separate from upstream token and vault-session lifetimes. |
200
+ | `now` | `Date.now` | Epoch-millisecond clock; sampled at construction and creation for TTL checks and used by vault proof/nonce/replay policy. Shared instances and store clocks must agree. |
201
+
202
+ Construction takes an internal resolved snapshot of the options object without mutating it: normalized values are stored on the snapshot, `cookie`/`trustedOrigins`/`config` containers are shallow-copied, `transactionCookie` and `fingerprintRecognition` are resolved into detached frozen configurations, and `storeProvider`/`hooks`/`tokenIssuer`/`now` service references are retained live (never deep-cloned). Frozen inputs work, reused inputs are not mutated, and mutating or replacing the caller object after creation has no effect on the created router.
203
+
204
+ `fetchUserInfo` defaults to enabled **when a UserInfo endpoint exists and the token response supplies an access token**; `false` disables it. `config` itself is optional in the declaration, but valid provider values (`issuer`/`clientId` or the complete manual set) are required by construction. Vault `deviceBinding` configures the lifecycle router; API `deviceBinding` must be configured separately on each accepting API middleware.
205
+
206
+ ## DPoP Quick Start (local JWT + protected API)
207
+
208
+ DPoP binds a login to a browser-held private key. The complete server below issues a short-lived local JWT with `cnf.jkt` and protects the API with the same replay provider. It uses the development memory store; select a shared Redis/MongoDB provider for multiple processes. Direct imports of `jose` and `cors` are application dependencies:
209
+
210
+ ```sh
211
+ pnpm add @web-ts-toolkit/express-oidc-vault @web-ts-toolkit/express-oidc-vault-memory-store express jose cors
212
+ pnpm add -D @types/express @types/node @types/cors typescript
213
+ ```
214
+
215
+ ### Complete DPoP server
216
+
217
+ Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, optional `OIDC_CLIENT_SECRET`, and a stable strong random `APP_JWT_SECRET` encoding at least 32 bytes. Register **`https://api.example.com/auth/oidc/callback`** with the IdP. Terminate HTTPS at the configured public backend origin and preserve the public vault mount path.
218
+
219
+ ```ts
220
+ import express from 'express';
221
+ import cors from 'cors';
222
+ import { SignJWT } from 'jose';
223
+ import {
224
+ createOidcVaultMiddleware,
225
+ createOidcVaultAccessTokenMiddleware,
226
+ createOidcVaultJwtAccessTokenValidator,
227
+ } from '@web-ts-toolkit/express-oidc-vault';
228
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
229
+
230
+ const backendOrigin = 'https://api.example.com';
231
+ const frontendOrigin = 'https://frontend.example.com';
232
+ const audience = 'app-api-v1';
233
+ const rawSecret = process.env.APP_JWT_SECRET;
234
+ if (!rawSecret || Buffer.byteLength(rawSecret, 'utf8') < 32) {
235
+ throw new Error('APP_JWT_SECRET must encode at least 32 strong random bytes.');
236
+ }
237
+ const signingKey = new TextEncoder().encode(rawSecret);
238
+ const store = createMemoryOidcVaultStore({ dpopReplayMaxEntries: 100_000 });
239
+ const app = express();
240
+ app.use(
241
+ cors({
242
+ origin: frontendOrigin,
243
+ credentials: true,
244
+ allowedHeaders: ['Content-Type', 'Authorization', 'DPoP', 'X-Device-Fingerprint'],
245
+ exposedHeaders: ['DPoP-Nonce', 'WWW-Authenticate'],
246
+ }),
247
+ );
248
+ app.use(
249
+ createOidcVaultMiddleware({
250
+ backendOrigin,
251
+ basePath: '/auth/oidc',
252
+ config: {
253
+ issuer: process.env.OIDC_ISSUER,
254
+ clientId: process.env.OIDC_CLIENT_ID,
255
+ clientSecret: process.env.OIDC_CLIENT_SECRET,
256
+ },
257
+ frontendRedirectUri: `${frontendOrigin}/callback`,
258
+ trustedOrigins: [frontendOrigin],
259
+ storeProvider: store,
260
+ sessionTransport: 'body',
261
+ sessionTtlMs: 8 * 60 * 60 * 1000,
262
+ deviceBinding: { mode: 'required' }, // ES256, age 60s, skew 5s, nonces off.
263
+ tokenIssuer: {
264
+ async issue({ session, deviceBinding }) {
265
+ if (!deviceBinding) throw new Error('A verified DPoP binding is required.');
266
+ const accessToken = await new SignJWT({ scope: session.scope, cnf: { jkt: deviceBinding.jkt } })
267
+ .setSubject(session.subject)
268
+ .setProtectedHeader({ alg: 'HS256' })
269
+ .setIssuer(backendOrigin)
270
+ .setAudience(audience)
271
+ .setIssuedAt()
272
+ .setExpirationTime('5m')
273
+ .sign(signingKey);
274
+ return { accessToken, tokenType: 'DPoP', expiresIn: 300 };
275
+ },
276
+ },
277
+ }),
278
+ );
279
+ app.use(
280
+ '/api',
281
+ createOidcVaultAccessTokenMiddleware({
282
+ validator: createOidcVaultJwtAccessTokenValidator({
283
+ key: signingKey,
284
+ issuer: backendOrigin,
285
+ audience,
286
+ algorithms: ['HS256'],
287
+ }),
288
+ deviceBinding: { mode: 'required', publicOrigin: backendOrigin, replayNamespace: 'app-api-v1', replayStore: store },
289
+ }),
290
+ );
291
+ app.get('/api/profile', (req, res) => {
292
+ res.json({ subject: req.auth?.subject, scope: req.auth?.scope, binding: req.auth?.deviceBinding?.jkt });
293
+ });
294
+ app.listen(3000);
295
+ ```
296
+
297
+ Local HS256 signing and ES256 proof signing are independent. Use the **verified `IssueTokenInput.deviceBinding`** for `cnf.jkt`; a mutable profile/session field is not proof authority. This JWT deliberately contains no vault `sessionId`/`sid`, so switching to cookie transport keeps that handle out of browser-readable tokens. APIs still independently verify JWT signature/issuer/audience/expiry and request proof. Authorization remains application-owned.
298
+
299
+ For cookie transport, change `sessionTransport` to `'cookie'` and configure the frontend for that same mount/transport. For multiple mounts, give each a distinct `transactionCookie.name` and session `cookie.name`. Nonces are optional: to enable them, supply `nonce: { secret: sharedRandomBytes, lifetimeSeconds: 60 }` in **each** vault/API proof policy; use at least 32 random bytes shared by instances of that protection space. A random secret created anew on each instance is unsuitable for shared challenge verification.
300
+
301
+ ### Device-binding policy and issuance contracts
302
+
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.
304
+
305
+ `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
+
307
+ | Vault policy | New login | Existing records |
308
+ | ----------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
309
+ | Omitted | Legacy unbound GET; POST exists only if recognition is configured | Unbound compatible; bound credentials fail closed |
310
+ | `{}` / `optional` | GET unbound; JSON POST binds only when a valid proof is supplied, otherwise cookie-only | Legacy unbound allowed; supplied later proof validates but never enrolls |
311
+ | `required` | JSON POST with proof; GET rejected before discovery/allocation | Unbound transaction/code/session use rejected; bound credentials require their original key |
312
+
313
+ There is **no `legacyPolicy` or downgrade switch**. Migrate clients to proof-aware POST login and configure every API before selecting `required`; old unbound sessions then need fresh login. Disabling the feature does not make existing bound sessions/JWTs usable as Bearer.
314
+
315
+ The proof profile is separate from local JWT signing: `algorithms` defaults to `['ES256']` and permits explicit `PS256`/`RS256`; `proofMaxAgeSeconds` defaults to 60 (integer 1–300), `clockSkewSeconds` to 5 (integer 0–30). `nonce` is off/`false` by default; an enabled `{ secret: Uint8Array, lifetimeSeconds?: number }` requires at least 32 shared random bytes and defaults to 60 seconds (integer 1–300). Policy and algorithm containers are detached/frozen, and nonce bytes are copied at construction.
316
+
317
+ The shared nonce policy signs versioned, random HMAC-SHA-256 challenges bound to the key and protection space, with no per-client nonce storage. It accepts any authentic live issued nonce for parallel fresh proofs; proof age and single-use JTI reservation still apply. Challenges are at most 512 bytes and issued only after other verification prerequisites, before replay allocation. Configured APIs write fixed `OIDC_VAULT_USE_DPOP_NONCE` JSON, one `DPoP-Nonce` header, and `no-store` with HTTP 401 and the DPoP nonce challenge. DPoP-bearing vault POSTs use the same fixed JSON/header with HTTP 400 before transaction/code/session mutation, upstream calls or hooks. Clients retry once with a fresh proof/JTI/iat/signature. Headerless callback and no-proof unbound requests do not require a nonce.
318
+
319
+ Sessions, authorization transactions, and exchange codes carry optional `deviceBinding: { type: 'dpop', jkt: string }`; transactions/codes also carry optional `browserBindingHash`. Only the canonical RFC 7638 SHA-256 thumbprint is persisted, without a JWK, private key, proof algorithm, or historical mode. Core retains security-owned binding independently of mutable precreate hooks and issuer inputs. All three built-ins implement live preflight reads, atomic exact/null match-and-consume, lineage revocation context, immutable rotation, and shared bounded replay admission; see the provider contracts below. These store primitives do not themselves verify HTTP proofs.
320
+
321
+ ### POST login and browser-authenticated callback
322
+
323
+ Configure the vault with the existing stronger store and an opt-in policy:
324
+
325
+ ```ts
326
+ import express from 'express';
327
+ import { createOidcVaultMiddleware, type OidcVaultTransactionCookieOptions } from '@web-ts-toolkit/express-oidc-vault';
328
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
329
+
330
+ const transactionCookie: OidcVaultTransactionCookieOptions = { sameSite: 'lax' };
331
+ const app = express();
332
+ app.use(
333
+ createOidcVaultMiddleware({
334
+ backendOrigin: 'https://api.example.com',
335
+ frontendRedirectUri: 'https://frontend.example.com/callback',
336
+ trustedOrigins: ['https://frontend.example.com'],
337
+ config: { issuer: process.env.OIDC_ISSUER, clientId: process.env.OIDC_CLIENT_ID },
338
+ storeProvider: createMemoryOidcVaultStore(), // Local development; shared durable provider in production.
339
+ deviceBinding: { mode: 'required' },
340
+ transactionCookie,
341
+ }),
342
+ );
343
+ ```
344
+
345
+ 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
+
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:
348
+
349
+ ```ts
350
+ import type { OidcVaultLoginInitiationInput, OidcVaultLoginInitiationResult } from '@web-ts-toolkit/express-oidc-vault';
351
+
352
+ async function initiateLogin(freshDpopProof: string): Promise<OidcVaultLoginInitiationResult> {
353
+ // Proof: typ=dpop+jwt, allowed public JWK, POST, pinned absolute login htu,
354
+ // fresh iat/JTI/signature, nonce if challenged. No access-token ath is needed.
355
+ const body: OidcVaultLoginInitiationInput = { returnTo: '/signed-in' };
356
+ const response = await fetch('https://api.example.com/auth/oidc/login', {
357
+ method: 'POST',
358
+ credentials: 'include',
359
+ headers: { 'Content-Type': 'application/json', DPoP: freshDpopProof },
360
+ body: JSON.stringify(body),
361
+ });
362
+ if (!response.ok) throw new Error('Login initiation failed.');
363
+ const value: unknown = await response.json();
364
+ if (
365
+ typeof value !== 'object' ||
366
+ value === null ||
367
+ !('authorizationUrl' in value) ||
368
+ typeof value.authorizationUrl !== 'string'
369
+ ) {
370
+ throw new Error('Invalid login initiation response.');
371
+ }
372
+ const result: OidcVaultLoginInitiationResult = { authorizationUrl: value.authorizationUrl };
373
+ return result; // Navigate to result.authorizationUrl using the same browser/profile.
374
+ }
375
+ ```
376
+
377
+ - **Admission order:** source Origin (valid Referer fallback only when Origin is absent), JSON/returnTo, shared proof/nonce/replay admission, then discovery/hooks/transaction persistence. POST source checks apply to **both body and cookie session transports**, and missing/null/duplicate/untrusted sources fail closed. Proof target uses pinned `backendOrigin` + externally visible `req.originalUrl` path; Host/forwarded headers cannot change it. Browser proofs are never sent upstream. Replay reservations survive later discovery/store/hook failures; retry needs a fresh proof.
378
+ - **Temporary cookie:** every successful POST creates a fresh **32-byte random base64url** secret and stores only its SHA-256 hash. Defaults are HTTPS `__Host-oidc_vault_transaction` or HTTP `oidc_vault_transaction`, host-only, `Path=/`, HttpOnly, Secure on HTTPS, `SameSite=Lax`. Only `name` and `sameSite` are public overrides; `none` requires HTTPS and an explicit cross-site SPA choice. Strict/Domain/path/HttpOnly/Secure opt-outs fail construction. Session/transaction names must differ; multiple vault mounts need distinct transaction names.
379
+ - **Navigation authentication:** `GET /callback` reads state and the selected cookie, preflights live identity/policy/hash, then atomically consumes the exact stored key/hash match before any upstream token request, session, or code. It does not treat a navigation as a DPoP-bearing request. Missing/wrong/noncanonical/duplicate selected cookies do not spend or clear the transaction; unrelated malformed cookies are ignored. Required-mode legacy records and disabled-mode bound records fail closed. POST transaction metadata reserves `oidcVaultTransactionProvider` for the exact resolved issuer/client ID; returned atomic authority is rechecked before upstream use.
380
+ - **Propagation/cleanup:** successful callback copies the original jkt into the session and key/hash into the code independently of mutable hooks; the transaction cookie is retained and its deadline shortened to the code's expiry. Max-Age and Expires round down to the record deadline, including hook/store delays. An authenticated provider-error callback consumes state and clears the temporary cookie with fixed `400 OIDC_VAULT_CALLBACK_ERROR` / `OIDC callback failed.` Other authenticated terminal callback failures also clear it; mismatches leave cookies untouched. Abandoned records/cookies expire. A fresh initiation replaces the single cookie, so one browser/vault mount supports one pending flow; earlier flows can require restart.
381
+ - **Exchange:** the retained cookie and a fresh proof from the original initiating key authenticate bound codes before atomic consumption; success or authenticated terminal issuance failure clears the temporary cookie. Origin/Referer checks apply to guarded JSON **and URL-encoded** exchange in both session transports. Missing/wrong cookie, wrong/missing/stale/replayed proof, identity/binding mismatch and nonce challenge do not spend an honest code or clear its cookie. Browser CORS must allow explicit trusted origins, credentials, Content-Type/DPoP and expose DPoP-Nonce/WWW-Authenticate; third-party cookie blocking can require same-site deployment.
382
+
383
+ ### Sender-constrained exchange, refresh and logout
384
+
385
+ Use **the same persistent private key selected at POST login** for every request. Exchange cannot claim a new key from the first presenter, JSON/JWK/thumbprint fields, hooks or a later optional proof.
386
+
387
+ | POST route | Authority and proof | Successful result |
388
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
389
+ | `<basePath>/exchange` | `{ code }`, temporary transaction cookie in **both** transports, trusted Origin/Referer for guarded codes, fresh original-key `DPoP` header when bound | `OidcVaultExchangeResult`; clears transaction cookie, sets session cookie only in cookie transport |
390
+ | `<basePath>/refresh` | Body `{ sessionId }` or session cookie, fresh original-key `DPoP` header when bound; cookie transport also checks Origin/Referer | Same result with a rotated session handle and unchanged binding/lineage/absolute expiry |
391
+ | `<basePath>/logout` | Same handle/cookie and original-key proof; optional `{ redirect: true }` on live handles | `200 { loggedOut: true }` or best-effort upstream redirect after local revocation |
392
+
393
+ Vault POST proofs use the exact method and pinned `backendOrigin` plus externally visible path, fresh `iat`/`jti`, and nonce if challenged. They **do not require an Authorization access token or `ath`**, so refresh/logout work after local JWT expiry. Browser proofs are never forwarded to the upstream IdP. API requests separately require `Authorization: DPoP <accessToken>`, a fresh proof with `ath`, and request-aware validation.
394
+
395
+ 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
+
397
+ ```ts
398
+ import type { OidcVaultExchangeResult } from '@web-ts-toolkit/express-oidc-vault';
399
+
400
+ async function exchangeBoundCode(code: string, freshDpopProof: string): Promise<OidcVaultExchangeResult> {
401
+ const response = await fetch('https://api.example.com/auth/oidc/exchange', {
402
+ method: 'POST',
403
+ credentials: 'include',
404
+ headers: { 'Content-Type': 'application/json', DPoP: freshDpopProof },
405
+ body: JSON.stringify({ code }),
406
+ });
407
+ if (!response.ok) throw new Error('Code exchange failed.');
408
+ const value: unknown = await response.json();
409
+ if (
410
+ typeof value !== 'object' ||
411
+ value === null ||
412
+ !('accessToken' in value) ||
413
+ typeof value.accessToken !== 'string' ||
414
+ !('tokenType' in value) ||
415
+ value.tokenType !== 'DPoP' ||
416
+ !('expiresIn' in value) ||
417
+ typeof value.expiresIn !== 'number' ||
418
+ !Number.isSafeInteger(value.expiresIn) ||
419
+ value.expiresIn < 0 ||
420
+ ('sessionId' in value && typeof value.sessionId !== 'string')
421
+ ) {
422
+ throw new Error('Invalid bound credential response.');
423
+ }
424
+ // Project validated credentials. Keep the access token in memory; in body
425
+ // transport persist sessionId in sessionStorage. Cookie JSON omits the ID.
426
+ return {
427
+ accessToken: value.accessToken,
428
+ tokenType: value.tokenType,
429
+ expiresIn: value.expiresIn,
430
+ ...('sessionId' in value ? { sessionId: value.sessionId as string } : {}),
431
+ };
432
+ }
433
+ ```
434
+
435
+ Refresh sends a fresh proof to `/refresh` with `{ sessionId }` in body transport or `{}` plus `credentials: 'include'` in cookie transport. Logout does likewise at `/logout`. Login/exchange always need `credentials: 'include'` for the temporary cookie. When `tokenIssuer` is omitted, the backend still enforces binding but returns no local `accessToken`/`expiresIn`/`tokenType`; the example above deliberately expects a configured issuer.
436
+
437
+ - **Precommit order:** capture request/record/provider authority; authenticate mode, code/session agreement, Origin/cookie and any enrolled recognition; verify signature/key/target/time/nonce and reserve shared replay; then consume/issue or use the upstream refresh token/rotate, or invoke logout hooks/revoke. Exchange atomically matches `expectedSessionId` and both exact/null binding fields and rechecks the returned code/session authority before issuance. Invalid proofs cannot burn upstream refresh tokens. Reservations survive downstream failure; a retry needs a fresh proof.
438
+ - **Legacy migration:** `required` rejects unbound records before state/upstream work. `optional` permits legacy unbound sessions/codes, explicitly matching omitted fields. Any supplied proof still validates/nonces/reserves but **never enrolls or rebinds** the legacy record. Cookie-only POSTs still need their temporary cookie and guarded-exchange Origin. Disabled binding rejects bound sessions/codes and built-in live/alias logout contexts.
439
+ - **Immutable refresh/issuance:** rotation uses the original key, provider, subject, logical ID and absolute expiry; fresh ID/UserInfo profile precedence is preserved. Issuers and refresh/logout hooks receive owned plain session containers. Hook/issuer mutations cannot redirect lineage revocation or replace response/profile/binding authority. Invalid issuer output retains original-lineage rollback and sanitized/no-store errors.
440
+ - **Logout aliases:** built-ins resolve `getSessionRevocationContext(handle)` for live handles and unexpired aliases, checking the surviving lineage's provider/key rather than an alias's missing historical binding. A mismatch leaves the lineage/cookie intact. No live target is idempotent success without deletion/replay/hooks (the selected transport's handle/cookie is still required). Aliases grant revocation only, not refresh or upstream redirect credentials; alias-only logout retains no live-session hook notification. Signed backchannel logout is unchanged. Stateless local JWTs remain valid until expiry unless the API validator checks application revocation state.
441
+
442
+ ## Optional Fingerprint Recognition (Not PoP)
443
+
444
+ `fingerprintRecognition?: OidcVaultFingerprintRecognitionOptions` is a **separate opt-in browser recognition/change-detection policy**. Fingerprint matching is recognition/change detection, not theft prevention against deliberate copying. A FingerprintJS `visitorId` or another browser-computed identifier is copyable/spoofable; matching it does not prove possession of a private key, identify a physical device, or constrain API access. Recognition never satisfies `deviceBinding.mode: 'required'`.
445
+
446
+ Enable it independently or alongside DPoP, using named package-root imports:
447
+
448
+ ```ts
449
+ import {
450
+ createOidcVaultMiddleware,
451
+ type OidcVaultFingerprintRecognitionOptions,
452
+ } from '@web-ts-toolkit/express-oidc-vault';
453
+ import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
454
+
455
+ const fingerprintRecognition: OidcVaultFingerprintRecognitionOptions = {};
456
+ const vault = createOidcVaultMiddleware({
457
+ backendOrigin: 'https://api.example.com',
458
+ frontendRedirectUri: 'https://frontend.example.com/callback',
459
+ trustedOrigins: ['https://frontend.example.com'],
460
+ config: { issuer: process.env.OIDC_ISSUER, clientId: process.env.OIDC_CLIENT_ID },
461
+ storeProvider: createMemoryOidcVaultStore(), // Development; shared Redis/Mongo provider in production.
462
+ fingerprintRecognition, // Or { headerName: 'X-App-Browser' }.
463
+ sessionTtlMs: 8 * 60 * 60 * 1000,
464
+ // Add deviceBinding: { mode: 'required' } for cryptographic sender constraint.
465
+ });
466
+ ```
467
+
468
+ - **Wire signal:** default `X-Device-Fingerprint`; a custom `headerName` must be a valid HTTP field name without authentication, cookie, origin, content, or transport-header collisions (case-insensitive). Supply exactly **one raw field**, with a **nonempty printable ASCII value, at most 256 bytes**. Core does not trim, case-fold, split commas, or normalize the opaque server-observed value. Duplicate fields (even identical/case-varied), invalid characters, empty or oversized opted-in signals return **400 `OIDC_VAULT_INVALID_FINGERPRINT` / `Fingerprint signal is invalid.`** before credential work. Omitting the option means no capture/check.
469
+ - **Enrollment:** only a supplied signal on **JSON POST login** enrolls recognition. An absent signal intentionally creates an unenrolled session; legacy/GET login stays unenrolled even if a header, profile claim, or later exchange/refresh signal is supplied. Fingerprint-only POST login is unbound and rejects a supplied DPoP header while DPoP is disabled. It still uses the single temporary HttpOnly transaction cookie and trusted Origin/Referer in **both session transports**. Construction requires all six guarded-store capabilities, just like the other opt-in flow; all built-ins implement them. The headerless callback authenticates the cookie and copies the original transaction evidence into the original new session.
470
+ - **Precommit comparison:** enrolled exchange/refresh require the original signal **before proof/nonce/replay admission, guarded code consumption, upstream refresh-token use, or rotation**. Missing/mismatch returns **403 `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` / `Browser recognition changed; sign in again.`** without consuming codes, burning upstream refresh tokens, rotating/revoking sessions, or setting/clearing cookies. Handle it by clearing frontend auth state and starting a fresh POST login. There is no automatic tolerance, re-enrollment, or value rotation at exchange/refresh. A fresh login establishes a new value/new session; an earlier session remains until explicit revocation or expiry.
471
+ - **Private metadata:** SHA-256 is stored as canonical base64url in `transaction.metadata.oidcVaultFingerprintRecognition`, then **`session.metadata.oidcVaultFingerprintRecognition = { version: 1, hash }`**. This key is reserved. Core preserves its original value/unenrolled absence through mutable precreate hooks, refresh, and portable metadata copies; profile claims cannot enroll/rebind it. Core stores no raw signal, emits no recognition evidence in user/credential responses, omits it from token-issuer session input, and logs neither raw signals nor hashes. Issuers should use allowlisted claims; hooks and request/proxy logging must also avoid fingerprint headers and reserved metadata.
472
+ - **Logout and APIs:** recognition checks apply only to exchange/refresh. Live/alias logout and signed backchannel logout retain their existing rules, so a changed fingerprint does not prevent revocation; bound logout still needs its original DPoP key. Rotation aliases contain no recognition metadata and do not authenticate refresh. Fingerprint-only local tokens remain `Bearer`; API recognition/risk policy is application-owned. DPoP JWTs still need request-aware proof enforcement at every API.
473
+
474
+ ### Frontend signal adapter
475
+
476
+ Keep collection in the frontend; the backend has **no FingerprintJS dependency**. Inject a current signal source and obtain headers for each POST login/exchange/refresh. For an intentionally unenrolled choice return `undefined`; collection failures should stop the operation rather than silently disabling an enrolled check. A minimal generic adapter (no backend runtime imports) is:
477
+
478
+ ```ts
479
+ async function recognitionHeaders(getSignal: () => Promise<string | undefined>): Promise<Record<string, string>> {
480
+ let signal: string | undefined;
481
+ try {
482
+ signal = await getSignal();
483
+ } catch {
484
+ throw new Error('Browser recognition is unavailable.');
485
+ }
486
+ if (signal === undefined) return {};
487
+ if (
488
+ signal.length === 0 ||
489
+ signal.length > 256 ||
490
+ Array.from(signal).some((character) => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) > 0x7e)
491
+ ) {
492
+ throw new Error('Fingerprint signal is invalid.');
493
+ }
494
+ return { 'X-Device-Fingerprint': signal };
495
+ }
496
+
497
+ async function initiateRecognizedLogin(getSignal: () => Promise<string | undefined>): Promise<string> {
498
+ const response = await fetch('https://api.example.com/auth/oidc/login', {
499
+ method: 'POST',
500
+ credentials: 'include',
501
+ headers: { 'Content-Type': 'application/json', ...(await recognitionHeaders(getSignal)) },
502
+ body: JSON.stringify({}), // Add a fresh DPoP header separately when binding is enabled.
503
+ });
504
+ if (!response.ok) throw new Error('Login initiation failed.');
505
+ const value: unknown = await response.json();
506
+ if (
507
+ typeof value !== 'object' ||
508
+ value === null ||
509
+ !('authorizationUrl' in value) ||
510
+ typeof value.authorizationUrl !== 'string'
511
+ ) {
512
+ throw new Error('Invalid login initiation response.');
513
+ }
514
+ return value.authorizationUrl; // Navigate after initiation.
515
+ }
516
+ ```
517
+
518
+ 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
+
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):
521
+
522
+ ```ts
523
+ // In your frontend, after choosing to collect recognition:
524
+ import {
525
+ createDeviceFingerprint,
526
+ fingerprintJsSignalSource,
527
+ type FingerprintJsAgent,
528
+ } from '@web-ts-toolkit/oidc-vault-dpop-client';
529
+
530
+ function recognitionWithOptionalFingerprintJs(load: () => Promise<FingerprintJsAgent>) {
531
+ return createDeviceFingerprint(fingerprintJsSignalSource(load));
532
+ }
533
+ // If YOUR frontend installs @fingerprintjs/fingerprintjs, inject:
534
+ // recognitionWithOptionalFingerprintJs(() => FingerprintJS.load());
535
+ // The agent is structurally { get(): Promise<{ visitorId: string }> }.
536
+ // const headers = await recognition.headers(); // Current get() on each operation.
537
+ ```
538
+
539
+ The utility lazily shares agent load, **does not cache/persist the identifier**, bounds signals, and emits fixed collection errors without vendor diagnostics. The [full example app](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md) integrates it through real login/callback/exchange/refresh and cookie-tab recognition checks, independently of fresh DPoP proofs.
540
+
541
+ ### Privacy and retention
542
+
543
+ Disclose what is collected, why matching is used, the session/transaction retention, and how users restart login after a change. Hashing is **not anonymization**: a stable/low-entropy identifier and its deterministic hash remain correlation data and can be copied or guessed. The raw header is transient and must be excluded from application/proxy logs. The enrollment hash exists in a pending authorization transaction (default 10-minute TTL), then lasts with the vault session through refresh; it is **not** limited by local JWT or upstream access-token expiry. Set an explicit `sessionTtlMs` or an application/store lifetime: unset assigns no default session expiry. Account for physical store cleanup and backups after logical expiry/deletion; abandoned transaction records expire under their store policy. Logout/revocation deletes session records under that policy. The frontend utility stores no identifier.
544
+
545
+ ## Absolute Session Lifetime
546
+
547
+ The quick start opts in with `sessionTtlMs: 8 * 60 * 60 * 1000`. New server-side sessions receive `expiresAt = now + sessionTtlMs` at **callback session creation**, not login start. Refresh preserves that timestamp. At `now >= expiresAt`, the store treats the session as expired, so exchange and refresh can no longer use it, even if an exchange code is still live.
548
+
549
+ Before `onBeforeSessionCreate`, the session already has its expiry. A hook may shorten it with a valid integer epoch-millisecond timestamp. Removing, extending, or assigning an invalid expiry restores the original cap after the hook; hook delay and changes to `createdAt` do not move that cap. For example, add this optional hook to the middleware options to shorten new sessions to one hour:
550
+
551
+ ```ts
552
+ hooks: {
553
+ onBeforeSessionCreate({ session }) {
554
+ if (session?.expiresAt !== undefined) {
555
+ session.expiresAt = Math.min(session.expiresAt, session.createdAt + 60 * 60 * 1000);
556
+ }
557
+ },
558
+ },
559
+ ```
560
+
561
+ Omitting `sessionTtlMs` assigns no default session expiry and retains application/hook/store-owned policy. Enabling it affects new sessions; it does not retrofit existing sessions. Upstream OAuth `expires_in`, local access-token lifetime, and vault-session lifetime are independent.
562
+
563
+ `authorizationTransactionTtlMs` (default 10 minutes), `exchangeCodeTtlMs` (default 30 seconds), and optional `sessionTtlMs` must be positive safe-integer numbers of milliseconds. Construction rejects zero, negative, fractional, nonnumeric, null, NaN, infinite, and unsafe values. It samples `now` (default `Date.now`): the clock and computed expiry must be integer epoch milliseconds within JavaScript Date's inclusive ±8,640,000,000,000,000 ms range, with expiry after now. Record creation rechecks computed expiries; an unusable later clock/expiry returns sanitized HTTP 500 / `OIDC_VAULT_INTERNAL_ERROR` before new transaction/session/code persistence, with the original error available to `hooks.onError`.
179
564
 
180
565
  ## Frontend Integration Example
181
566
 
182
- The backend flow is only half of the integration. In default body transport mode, keep `accessToken` in memory, mirror `sessionId` into `sessionStorage`, and deduplicate refresh calls.
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
867
+
868
+ For a bound flow, install the published browser client
869
+ [`@web-ts-toolkit/oidc-vault-dpop-client`](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md)
870
+ in your frontend (it brings `jose` + `idb` as runtime dependencies) and use its
871
+ 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`.
876
+
877
+ ```sh
878
+ pnpm add @web-ts-toolkit/oidc-vault-dpop-client
879
+ ```
880
+
881
+ ```ts
882
+ import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
883
+
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'.
886
+
887
+ export const signIn = (): Promise<void> => session.login('/callback?transport=body');
888
+
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.
898
+ }
899
+ }
900
+
901
+ export async function getBoundProfile(): Promise<unknown> {
902
+ const response = await fetchWithDpop(
903
+ { session, apis: [{ origin: backendOrigin, replayNamespace: 'oidc-vault-dpop-example-api' }] },
904
+ `${backendOrigin}/api/profile`,
905
+ );
906
+ if (!response.ok) throw new Error('Protected API request failed.');
907
+ return response.json();
908
+ }
909
+ export const signOut = (): Promise<void> => session.logout();
910
+ ```
911
+
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`).
913
+
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.
915
+
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.
917
+
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.
919
+
920
+ ### Default unbound bearer frontend
921
+
922
+ The following default **unbound** flow keeps `accessToken` in memory, mirrors `sessionId` into `sessionStorage`, and deduplicates refresh calls. Use the bound helpers above when enabling DPoP.
923
+
924
+ The shared promise below coordinates callers in this JavaScript context only. It does not coordinate tabs, backend instances, or response arrival order; see [Known Browser And Concurrency Limits](#known-browser-and-concurrency-limits).
183
925
 
184
926
  ```ts
185
927
  type AuthState = {
@@ -209,6 +951,21 @@ function setAuthState(payload: { accessToken?: string; sessionId: string }): voi
209
951
  persistSessionId(payload.sessionId);
210
952
  }
211
953
 
954
+ function readBodyCredentials(value: unknown): { accessToken?: string; sessionId: string } {
955
+ if (
956
+ !value ||
957
+ typeof value !== 'object' ||
958
+ !('sessionId' in value) ||
959
+ typeof value.sessionId !== 'string' ||
960
+ ('accessToken' in value && typeof value.accessToken !== 'string')
961
+ )
962
+ throw new Error('Invalid credential response.');
963
+ return {
964
+ sessionId: value.sessionId,
965
+ ...('accessToken' in value ? { accessToken: value.accessToken as string } : {}),
966
+ };
967
+ }
968
+
212
969
  function clearAuthState(): void {
213
970
  authState.accessToken = null;
214
971
  persistSessionId(null);
@@ -226,7 +983,7 @@ async function exchangeCallbackCode(code: string): Promise<void> {
226
983
  throw new Error('OIDC code exchange failed.');
227
984
  }
228
985
 
229
- setAuthState(await response.json());
986
+ setAuthState(readBodyCredentials(await response.json()));
230
987
  }
231
988
 
232
989
  async function refreshAuthState(): Promise<void> {
@@ -246,7 +1003,7 @@ async function refreshAuthState(): Promise<void> {
246
1003
  throw new Error('OIDC refresh failed.');
247
1004
  }
248
1005
 
249
- setAuthState(await response.json());
1006
+ setAuthState(readBodyCredentials(await response.json()));
250
1007
  }
251
1008
 
252
1009
  async function ensureFreshAccessToken(): Promise<void> {
@@ -259,14 +1016,20 @@ async function ensureFreshAccessToken(): Promise<void> {
259
1016
  await refreshPromise;
260
1017
  }
261
1018
 
262
- async function fetchWithAuth(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
1019
+ async function fetchWithAuth(input: string | URL, init: RequestInit = {}): Promise<Response> {
1020
+ // Minimal same-origin GET helper. Do not implicitly resubmit mutations/streams
1021
+ // or send this app's credentials to arbitrary origins or redirects.
1022
+ const url = new URL(input, location.href);
1023
+ if (url.origin !== location.origin || (init.method ?? 'GET').toUpperCase() !== 'GET' || init.body != null) {
1024
+ throw new Error('This helper accepts same-origin GET requests only.');
1025
+ }
263
1026
  const headers = new Headers(init.headers);
264
1027
 
265
1028
  if (authState.accessToken) {
266
1029
  headers.set('authorization', `Bearer ${authState.accessToken}`);
267
1030
  }
268
1031
 
269
- let response = await fetch(input, { ...init, headers });
1032
+ let response = await fetch(url, { ...init, headers, redirect: 'error', cache: 'no-store' });
270
1033
 
271
1034
  if (response.status !== 401 || !authState.sessionId) {
272
1035
  return response;
@@ -280,7 +1043,7 @@ async function fetchWithAuth(input: RequestInfo | URL, init: RequestInit = {}):
280
1043
  retryHeaders.set('authorization', `Bearer ${authState.accessToken}`);
281
1044
  }
282
1045
 
283
- response = await fetch(input, { ...init, headers: retryHeaders });
1046
+ response = await fetch(url, { ...init, headers: retryHeaders, redirect: 'error', cache: 'no-store' });
284
1047
  return response;
285
1048
  }
286
1049
 
@@ -317,7 +1080,7 @@ Recommended browser flow:
317
1080
 
318
1081
  1. Redirect the user to `GET /auth/oidc/login` when they click login.
319
1082
  2. On the frontend callback route, read `code` from the query string and call `exchangeCallbackCode(code)`.
320
- 3. Remove the `code` query parameter from the address bar after a successful exchange.
1083
+ 3. Remove the `code` query parameter with `history.replaceState` before asynchronous exchange to reduce URL/history exposure.
321
1084
  4. Call `bootstrapAuth()` once during app startup so a reloaded tab can recover from `sessionStorage`.
322
1085
  5. Use `fetchWithAuth(...)` or equivalent interceptor logic for normal API requests.
323
1086
 
@@ -340,6 +1103,17 @@ function setAuthState(payload: { accessToken?: string }): void {
340
1103
  authState.accessToken = payload.accessToken ?? null;
341
1104
  }
342
1105
 
1106
+ function readCookieCredentials(value: unknown): { accessToken?: string } {
1107
+ if (
1108
+ !value ||
1109
+ typeof value !== 'object' ||
1110
+ 'sessionId' in value ||
1111
+ ('accessToken' in value && typeof value.accessToken !== 'string')
1112
+ )
1113
+ throw new Error('Invalid cookie credential response.');
1114
+ return 'accessToken' in value ? { accessToken: value.accessToken as string } : {};
1115
+ }
1116
+
343
1117
  function clearAuthState(): void {
344
1118
  authState.accessToken = null;
345
1119
  }
@@ -355,7 +1129,7 @@ async function refreshAuthState(): Promise<void> {
355
1129
  throw new Error('OIDC refresh failed.');
356
1130
  }
357
1131
 
358
- setAuthState(await response.json());
1132
+ setAuthState(readCookieCredentials(await response.json()));
359
1133
  }
360
1134
 
361
1135
  async function ensureFreshAccessToken(): Promise<void> {
@@ -381,7 +1155,7 @@ async function exchangeCallbackCode(code: string): Promise<void> {
381
1155
  throw new Error('OIDC code exchange failed.');
382
1156
  }
383
1157
 
384
- setAuthState(await response.json());
1158
+ setAuthState(readCookieCredentials(await response.json()));
385
1159
  }
386
1160
  ```
387
1161
 
@@ -389,7 +1163,7 @@ For cross-origin cookie deployments, also remember:
389
1163
 
390
1164
  - the frontend requests must use `credentials: 'include'`
391
1165
  - the backend CORS policy must allow credentials
392
- - the cookie typically needs `SameSite=None` and `Secure`
1166
+ - same-site subdomains can use Lax; truly cross-site requests need `SameSite=None; Secure` and browser cookie permission
393
1167
  - set `trustedOrigins` so refresh and logout only accept requests from your frontend origin
394
1168
 
395
1169
  ## Backchannel Logout
@@ -408,7 +1182,7 @@ The middleware validates the `logout_token` against the provider JWKS and then r
408
1182
  - upstream `sid` when present
409
1183
  - otherwise `sub`
410
1184
 
411
- The logout token must include `iat`, `exp`, `jti`, the standard backchannel logout event claim, and either `sid` or `sub`. If the protected header includes `typ`, it must be `logout+jwt`; tokens without `typ` remain accepted for provider compatibility. Each `jti` is consumed once and remembered until the token `exp`, so replaying the same valid token returns a successful no-op response without repeating revocation hooks.
1185
+ The logout token must include `iat`, `exp`, `jti`, the standard backchannel logout event claim, and either `sid` or `sub`. If the protected header includes `typ`, it must be `logout+jwt`; tokens without `typ` remain accepted for provider compatibility. Each `jti` is reserved once per issuer/client ID (replay keys namespace the raw `jti`, so independent issuers sharing a store and reusing a `jti` do not suppress each other) and remembered until the token `exp`. The first presentation performs the idempotent session deletion and emits `onLogout`; a duplicate presentation repeats the same idempotent deletion without emitting `onLogout` unless the catch-up actually removed sessions (retry after a deletion failure still revokes and still delivers the hook). A sequential replay after completed revocation returns `revokedSessions: 0` without a hook. Hooks are therefore at-least-once under failure/concurrency, except a crash between durable deletion and hook delivery can lose that delivery. Pre-upgrade raw-`jti` replay records expire naturally with their token `exp` and are never matched by namespaced keys.
412
1186
 
413
1187
  Example request:
414
1188
 
@@ -476,6 +1250,7 @@ import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-re
476
1250
 
477
1251
  const app = express();
478
1252
  const redis = createClient({ url: process.env.REDIS_URL });
1253
+ redis.on('error', () => console.warn('OIDC vault Redis connection error.'));
479
1254
 
480
1255
  await redis.connect();
481
1256
 
@@ -510,6 +1285,8 @@ const app = express();
510
1285
  const mongo = new MongoClient(process.env.MONGODB_URI!);
511
1286
 
512
1287
  await mongo.connect();
1288
+ const storeProvider = createMongoOidcVaultStore({ db: mongo.db('app-auth') });
1289
+ await storeProvider.ready();
513
1290
 
514
1291
  app.use(
515
1292
  createOidcVaultMiddleware({
@@ -522,9 +1299,7 @@ app.use(
522
1299
  },
523
1300
  frontendRedirectUri: 'https://frontend.example.com/callback',
524
1301
  postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
525
- storeProvider: createMongoOidcVaultStore({
526
- db: mongo.db('app-auth'),
527
- }),
1302
+ storeProvider,
528
1303
  }),
529
1304
  );
530
1305
  ```
@@ -539,6 +1314,7 @@ import { createClient } from 'redis';
539
1314
 
540
1315
  const app = express();
541
1316
  const redis = createClient({ url: process.env.REDIS_URL });
1317
+ redis.on('error', () => console.warn('OIDC vault Redis connection error.'));
542
1318
 
543
1319
  await redis.connect();
544
1320
 
@@ -555,8 +1331,10 @@ app.use(
555
1331
  postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
556
1332
  sessionTransport: 'cookie',
557
1333
  cookie: {
1334
+ // Host-only (no `domain`): the browser scopes the cookie to
1335
+ // `api.example.com` and still sends it on credentialed cross-origin
1336
+ // requests from `https://frontend.example.com`.
558
1337
  deploymentMode: 'same-site',
559
- domain: '.example.com',
560
1338
  secure: true,
561
1339
  },
562
1340
  trustedOrigins: ['https://frontend.example.com'],
@@ -568,6 +1346,8 @@ app.use(
568
1346
  );
569
1347
  ```
570
1348
 
1349
+ Only set `cookie.domain` (for example `.example.com`) as an advanced expansion when sibling subdomains must share the credential. Sharing widens the credential trust boundary and is not required for normal cross-origin API requests.
1350
+
571
1351
  ## Main Exports
572
1352
 
573
1353
  - `createOidcVaultAccessTokenMiddleware(...)`
@@ -577,29 +1357,122 @@ app.use(
577
1357
  - `DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS`
578
1358
  - `DEFAULT_EXCHANGE_CODE_TTL_MS`
579
1359
  - `DEFAULT_OIDC_SCOPES`
1360
+ - `DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT`
580
1361
  - `OIDC_VAULT_ROUTE_PATHS`
1362
+ - `OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT`
581
1363
  - `normalizeOidcVaultBasePath(...)`
582
1364
  - `resolveOidcVaultConfig(...)`
583
1365
  - `resolveOidcVaultConfigFromEnv(...)`
584
1366
  - `type OidcVaultOptions`
1367
+ - `type OidcVaultDeviceBindingMode`, `OidcVaultDeviceBindingOptions`
1368
+ - `type OidcVaultDpopAlgorithm`, `OidcVaultDpopBinding`, `OidcVaultVerifiedDpopBinding`
1369
+ - `type OidcVaultDpopProofOptions`, `OidcVaultDpopNonceOptions`
1370
+ - `type OidcVaultTransactionCookieOptions`, `OidcVaultLoginInitiationInput`, `OidcVaultLoginInitiationResult`
1371
+ - `type OidcVaultFingerprintRecognitionOptions`
585
1372
  - `type OidcVaultHooks`
586
1373
  - `type OidcVaultStoreProvider`
587
1374
  - `type OidcVaultSession`
1375
+ - `type OidcVaultConfig`
1376
+ - `type OidcVaultSessionInput`
1377
+ - `OidcVaultStoreConflictError`
1378
+ - `OidcVaultDpopReplayCapacityError`
1379
+ - `type OidcVaultDeviceBindingStoreProvider`
1380
+ - `type OidcVaultDpopReplayStore`
1381
+ - `type OidcVaultRecordBindingMatch`
1382
+ - `type OidcVaultSessionRevocationContext`
1383
+ - `type OidcVaultExchangeResult`
1384
+ - `type OidcVaultLogoutResult`
588
1385
  - `type OidcVaultAccessTokenValidator`
1386
+ - `type OidcVaultRequestAwareAccessTokenValidator`
1387
+ - `type OidcVaultAccessTokenRequestInput`
1388
+ - `type OidcVaultAccessTokenConfirmation`
1389
+ - `type OidcVaultRequestAwareAccessTokenValidationResult`
1390
+ - `type OidcVaultApiDeviceBindingOptions`
1391
+ - `type OidcVaultAccessTokenMiddlewareOptions`
1392
+ - `type OidcVaultAuthContext`
589
1393
  - `type OidcVaultAuthenticatedRequest`
590
1394
  - `type OidcVaultJwtAccessTokenValidatorOptions`
591
1395
  - `type OidcVaultTokenIssuer`
1396
+ - `type OidcVaultTokenIssueResult`
1397
+ - `type OidcVaultProviderMetadata`
1398
+
1399
+ Other root-exported data contracts are `AuthorizationTransaction`/`AuthorizationTransactionInput`, `ExchangeCodeRecord`/`ExchangeCodeRecordInput`, `ConsumeAuthorizationTransactionIfMatchesInput`, `ConsumeExchangeCodeIfMatchesInput`, `ReserveDpopProofInput`, `RotateSessionInput`, `DeleteSessionsBySubjectInput`, `DeleteSessionsByProviderSessionIdInput`, `DeleteSessionsByLogicalSessionIdInput`, `ConsumeBackchannelLogoutTokenJtiInput`, `IssueTokenInput`, `OidcVaultAccessTokenValidationResult`, `OidcVaultAccessTokenMiddlewareErrorContext`, `OidcVaultHookContext`, `OidcVaultErrorContext`, `OidcVaultBackchannelLogoutResult`, `OidcVaultUserProfile`, `OidcVaultRouteName`, `OidcVaultSessionTransport`, `OidcVaultCookieOptions`, `OidcVaultCookieSameSite`, `OidcVaultCookieDeploymentMode`, `OidcVaultConfigMode`, `OidcVaultResolvedConfig`, and `OidcVaultEnv`. Import all of them with `import type { … } from '@web-ts-toolkit/express-oidc-vault'`; internal proof helpers and browser example functions are not backend exports.
592
1400
 
593
1401
  ## Store Provider Contract
594
1402
 
595
- The built-in memory, Redis, and MongoDB store packages share the same behavioral contract.
1403
+ The built-in memory, Redis, and MongoDB store packages share a portable subset with the lifetime, ID-reuse, serialization and deletion-accounting variations below. Their shipped READMEs describe backend startup/shutdown and resource bounds.
596
1404
 
597
- - `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are deliberate upserts keyed by `state`, `code`, and `sessionId`.
1405
+ - `createAuthorizationTransaction` and `createExchangeCode` are deliberate upserts keyed by `state` and `code`.
1406
+ - `createSession` duplicate-ID behavior is provider-specific: the memory and MongoDB providers replace the existing session (upsert), while the Redis provider rejects a live duplicate with `OidcVaultStoreConflictError` without changing the existing record or indexes (create-only, preserving index ownership). Portable callers must always create sessions with a fresh unused `sessionId` and handle `OidcVaultStoreConflictError`; reusing a live ID is non-portable. See `OidcVaultStoreProvider.createSession` for the full contract.
598
1407
  - Store metadata is portable when it is JSON-compatible: strings, finite numbers, booleans, null, arrays, and plain objects. Do not rely on functions, symbols, Dates, Maps, Sets, custom prototypes, undefined object properties, or object identity surviving a store round-trip.
599
- - Store methods return owned values or serialization round-trips. Mutating an input after a create call or mutating a returned value does not mutate persisted state.
600
- - Expiry timestamps are epoch milliseconds. Records are expired at `expiresAt <= now`; backchannel logout JTI expiry must be finite and in the future or the consume call returns `false` without storing the JTI.
1408
+ - For that portable domain, inputs are captured at invocation before asynchronous work, including nested provider/user/metadata fields and object deletion scopes. Mutating inputs immediately after calling, or mutating returned values, cannot change the committed value or eventual result. Memory uses `structuredClone`; MongoDB/Redis copy plain containers while preserving native backend serialization outside the portable subset. Opaque native objects/custom serializers have no portable mutation-isolation guarantee.
1409
+ - Expiry timestamps are epoch milliseconds. Memory/MongoDB check `expiresAt <= now`; Redis uses server-owned key TTLs and server time, not the optional application clock. Redis transaction/code **preflight getters and guarded consumes** additionally check payload expiry against `TIME`; legacy consumes and `getSession` retain key-TTL authority. Preserve matching TTLs/index scores on restore; guarded checks are not an external-data repair/migration service. Backchannel JTI expiry must be finite and in the future relative to the store clock or the consume returns `false` without storage.
1410
+ - Backchannel logout replay keys passed to `consumeBackchannelLogoutTokenJti` are opaque namespaced strings (issuer/client ID/`jti`); providers store them verbatim and need no schema change.
601
1411
  - `rotateSession` requires an existing source session and a distinct unused target `sessionId`. Equivalent missing-source, same-ID, and existing-target rotation conflicts throw `OidcVaultStoreConflictError` without deleting or overwriting source or target data.
602
- - Session rotation preserves the logical session ID when the next session omits one. Old public session IDs remain revocation aliases while the logical lineage remains live, so deleting by an old public ID can revoke the current rotated session.
1412
+ - Session rotation preserves the logical session ID when the next session omits one. With finite expiry, each old ID revokes its lineage only before its immediate successor's `expiresAt`; later rotations do not extend earlier aliases. `A -> B (T1) -> C (T2)` leaves A expiring at T1 even if T2 is later or absent. `getSession(A)` returns `null`. After that window use the live ID or logical/subject/provider-session deletion. With `A/L1 -> B/L1 -> C/L2`, the new B alias targets L2; retained A targets L1 with its original deadline. Memory eagerly retires inactive old-lineage aliases on rotation/upsert; MongoDB/Redis can retain them until expiry or explicit cleanup. Use distinct logical IDs for unrelated login families.
1413
+ - Without successor `expiresAt`, retention is provider-specific: memory and Redis impose no alias time limit and can accumulate arbitrarily many aliases; MongoDB uses `rotatedSessionAliasRetentionMs` (default 5 minutes). Core refresh uses the live ID and preserves expiry. This retains the [SVH-05 decision](https://github.com/egose/web-ts-toolkit/blob/main/docs/tasks/20260908-130120-oidc-vault-stores-health-follow-up.md#task-svh-05-decide-a-portable-rotation-alias-lifetime-contract).
1414
+ - Deleting a live public ID removes that record; an unexpired alias revokes its logical lineage. Subject/provider-session object inputs match each supplied issuer/client field; string inputs omit those filters. Logical deletion and aliases have no issuer/client filter. Scoped/direct deletion preserves unexpired aliases while a live member survives, including another provider scope. MongoDB retains alias rows under reused create IDs until expiry/lineage cleanup; memory/Redis clear target aliases on reuse.
1415
+ - Bulk counts cover primary records deleted, never alias cleanup: memory excludes expired sessions, MongoDB can count expired documents awaiting TTL cleanup, and Redis counts actual primary deletions including matching rotation successors. MongoDB scoped deletes repeat until an empty query (continuous arrivals can prolong them); Redis makes one cursor traversal. Later arrivals can survive and errors can follow committed deletions. Counts are not proof of an empty scope or a portable live-user census. Neither backend provides a global logout snapshot.
1416
+ - Operational bounds are local, not total-work guarantees: memory's 64-slot sweeps still rebuild full key snapshots and scan maps for lineage cleanup; MongoDB materializes affected IDs/survivors with potentially large `$in` sets; Redis SCAN/ZSCAN COUNT values are hints and Lua can materialize whole lineages/alias sets. Maintenance progress depends on operations/backend availability.
1417
+ - Compatibility: surviving aliases and invocation-time portable input ownership now remain intact across scoped deletion and caller mutation. Redis repairs keyed-record/index corruption conservatively and emits only fixed operation text for post-commit maintenance warnings. Arbitrary backend errors may still carry secrets: log allowlisted categories rather than records, raw errors, credential URLs or token-valued labels. Alias lifetime and duplicate-create variations remain intentional.
1418
+
1419
+ ### Guarded Records And Revocation Context
1420
+
1421
+ The factories in all three provider packages return `OidcVaultDeviceBindingStoreProvider` (MongoDB also exposes `ready()`). Its six required methods remain optional on the base `OidcVaultStoreProvider` for existing bearer-only custom stores:
1422
+
1423
+ | Method | Contract |
1424
+ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
1425
+ | `getAuthorizationTransaction(state)` | Detached live preflight record, without consumption. |
1426
+ | `consumeAuthorizationTransactionIfMatches({ state, match })` | Atomic expiry + complete binding/hash match + deletion. |
1427
+ | `getExchangeCode(code)` | Detached live preflight record, without consumption. |
1428
+ | `consumeExchangeCodeIfMatches({ code, expectedSessionId, match })` | Also atomically matches the session ID observed at preflight. |
1429
+ | `getSessionRevocationContext(sessionId)` | Live handle or unexpired alias resolves a currently live lineage; returns only logical ID, issuer/client identity and original key binding. |
1430
+ | `reserveDpopProof({ replayKey, expiresAt })` | Atomic shared replay admission through expiry, with capacity enforced. |
1431
+
1432
+ `match` always includes both fields: `{ deviceBinding: OidcVaultDpopBinding | null, browserBindingHash: string | null }`. **Null means the stored field must be absent/undefined.** It is never a wildcard. Valid transaction/code records are legacy (neither field), guarded unbound POST (cookie hash only), or bound POST (hash and key). Stored null, malformed/noncanonical hashes, extra binding fields, and a key without a browser hash are invalid, never legacy. Both hashes are canonical 43-character SHA-256 base64url values. Invalid binding/hash writes reject; invalid persisted data never yields credentials.
1433
+
1434
+ Reads are preflight snapshots, not locks. Matching consumers compare and delete atomically; a mismatch returns `null`, leaves a live record unchanged, and permits an honest retry. Concurrent matching calls have one winner. The original `consumeAuthorizationTransaction`/`consumeExchangeCode` methods refuse guarded records without spending them, including cookie-only records. Application-side get followed by unconditional deletion is not a supported guarded consume.
1435
+
1436
+ Rotation inherits the original binding if `nextSession.deviceBinding` is omitted/undefined. Supplying the same key is accepted; changing a bound key, explicitly removing it with null, or enrolling an unbound source is rejected before mutation. Use fresh IDs; the existing upsert/create-only differences above still apply.
1437
+
1438
+ Revocation context is detached authority, not alias authentication: `getSession(alias)` still returns `null`. Alias records do not need a stored binding; their **current live lineage** supplies it. Empty/expired lineages return `null`; malformed authority or inconsistent binding/provider identity across surviving members throws a fixed private diagnostic. No refresh/ID/access tokens, profile, metadata, or arbitrary provider fields are returned. Existing alias deadlines, lineage transitions and deletion counts are preserved. Logout authenticates this original context before lineage deletion, including when binding is disabled.
1439
+
1440
+ ### Per-Request DPoP Replay Cost And Capacity
1441
+
1442
+ DPoP replay is a reservation per accepted proof, including API traffic; it is substantially higher-volume than backchannel logout JTI traffic. The store accepts an opaque namespaced key (the shared orchestration derives `dpop:v1:` plus SHA-256 of the protection-space/key/JTI tuple). Never partition by instance, token, code or session, release a reservation after a route failure, or extend a duplicate's expiry. Retries need a new proof.
1443
+
1444
+ `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
+
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.
1447
+
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`.
1449
+
1450
+ | Provider | Admission/expiry work |
1451
+ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1452
+ | Memory | Map + indexed min-heap; one node per retained key, at most 64 expired nodes plus the requested expired key removed per admission. Heap operations are O(log N); replay never scans session/alias maps. Only callers sharing the same store object share replay state. |
1453
+ | Redis | One steady-state cached `EVALSHA`; Redis `TIME` inside Lua controls admission. One sorted set holds key/expiry/capacity state; hard `LIMIT 64` expiry cleanup, duplicate-first admission, no live eviction. Cold/NOSCRIPT calls additionally load/retry the script. |
1454
+ | MongoDB | A snapshot transaction writes one shared capacity/serialization row, checks duplicate IDs, reclaims at most 64 indexed expiry-accounting rows plus the requested expired key, then commits proof/accounting/capacity writes together. Physical proof TTL deletion cannot lose accounting: the separate indexed, non-TTL ledger retains reclaimable expiry evidence. |
1455
+
1456
+ Expired entries awaiting bounded cleanup can conservatively occupy capacity. Memory/Redis state is bounded by the configured limit; MongoDB has up to that many proof rows and accounting rows plus one capacity row. Mongo's shared admission row serializes traffic and can contend under load; an ordinary no-cleanup success uses seven data commands plus transaction commit, and a live duplicate uses the capacity write, proof lookup and commit. Cleanup and driver retries add commands. Read/consume and revocation costs are documented in each shipped provider README; these bounds are not latency or throughput guarantees.
1457
+
1458
+ ## Session Identity And Store Namespaces
1459
+
1460
+ Exchange, refresh and logout compare every stored `provider.issuer` and `provider.clientId` that is not undefined against the resolved middleware configuration. Built-in logout also checks the currently live lineage through unexpired aliases. Each known field must match independently. Stored identifiers are compared verbatim, without trimming or URL canonicalization; issuer trailing-slash variants are distinct. Configuration strings still receive construction-time trimming.
1461
+
1462
+ A known mismatch returns HTTP 401 with `{"code":"OIDC_VAULT_INVALID_SESSION","message":"Session is missing or expired."}` before discovery, upstream token use, local issuance, lifecycle hooks, rotation, or lineage deletion. It neither sets nor clears a cookie and produces no provider logout redirect. The normal `onError` observer runs without the foreign session in its context.
1463
+
1464
+ Legacy sessions with absent `provider`, an empty provider object, or omitted/undefined identity fields remain supported. Only known fields are checked: an omitted issuer permits cross-issuer use, an omitted client ID permits cross-client use, and entirely absent identity permits both. Refresh does not backfill identity.
1465
+
1466
+ For complete identity isolation, use separate store namespaces for **session/alias, exchange-code, and authorization-transaction records**. Opt-in/guarded exchange preflights identity before code consumption, and built-in logout preflights live/alias lineage identity before deletion. Disabled genuinely legacy bearer exchange preserves its historical consume-before-identity ordering, so a rejected foreign exchange can still spend that legacy code. Old custom bearer stores without `getSessionRevocationContext` retain legacy alias deletion behavior; they cannot enable device binding. Absent provider fields remain compatibility omissions, not isolation guarantees.
1467
+
1468
+ ## Known Browser And Concurrency Limits
1469
+
1470
+ - **Legacy browser binding:** unbound GET flows preserve their existing behavior: `state`, nonce, PKCE, and one-time codes alone do not bind completion to the initiating browser. A transferred legacy callback/frontend URL can cause session swapping; an unused legacy code is redeemable by another browser in either transport. Legacy `exchange` has no source-origin check and accepts URL-encoded forms. Opt-in POST callbacks and guarded exchange authenticate the temporary browser cookie; bound exchange/refresh/logout additionally require the original key. CORS and cookie refresh/logout Origin checks do not establish the legacy flow's missing binding.
1471
+ - **Refresh families:** local atomic rotation allows one winner, but overlapping requests can send the same upstream refresh token multiple times, including across backend instances. A single-use provider with reuse detection can revoke the entire upstream refresh family, leaving the local winner unable to refresh. Deduplicate frontend refreshes, including bootstrap and retry paths; a per-context promise is not a distributed guarantee.
1472
+ - **Cookie ordering:** a loser reaching a local rotation conflict (or a stale missing-session retry) clears the cookie. A late clear can erase the winner's cookie even while its server session remains live. Upstream-failure losers do not set a cookie. Response ordering is not enforced.
1473
+ - **Logout and stateless tokens:** local/provider/backchannel logout revoke vault refresh sessions, not outstanding stateless application access tokens. Those remain valid until their own expiry unless your validator checks application revocation state. A refresh racing logout can still return 200 and an access token after its lineage is deleted. Keep local tokens short-lived; immediate API revocation requires application-owned validation state. Vault-session expiry likewise does not revoke an already-issued stateless token.
1474
+
1475
+ Browser-bound backend proofs (BOV-02-FU1) are implemented for opt-in POST login/callback/exchange and bound refresh/logout/API. [Persistent browser keys and real-browser integration](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md) are implemented by the private example. Cross-instance refresh reservation (BOV-03-FU1) and stale-cookie ordering (BOV-03-FU2) remain proposed in the [boundary review](https://github.com/egose/web-ts-toolkit/blob/main/docs/tasks/20260908-070811-express-oidc-vault-boundary-review.md). DPoP replay admission and browser Web Locks do not implement a distributed refresh lease or response-ordering guarantee; different honest fresh proofs outside one coordinating frontend partition can still race a single-use upstream refresh family.
603
1476
 
604
1477
  ## Key Integration Notes
605
1478
 
@@ -608,21 +1481,27 @@ The built-in memory, Redis, and MongoDB store packages share the same behavioral
608
1481
  - `sessionId` should rotate on refresh.
609
1482
  - The frontend should deduplicate concurrent refresh calls so only one refresh is in-flight at a time.
610
1483
  - Upstream OAuth `expires_in` describes the upstream access token only. It does not set `OidcVaultSession.expiresAt` or shorten the refresh-token-backed vault session.
611
- - `OidcVaultSession.expiresAt`, when set by application code or store policy, is an explicit vault-session expiry in epoch milliseconds and remains enforced by store providers.
612
- - If only `OIDC_ISSUER` is configured, issuer discovery is used and the discovered issuer must match the configured issuer.
613
- - Provider discovery metadata and remote JWKS resolvers are cached in bounded process-wide maps keyed by configured issuer URL and `jwks_uri`; these keys are intended to come from static middleware configuration, not request input.
614
- - Successful discovery entries are reused for up to 10 minutes and both discovery and JWKS resolver maps retain at most 32 entries with oldest-entry eviction. Failed discovery requests are removed from the cache so a later request can retry.
615
- - Discovery, token, UserInfo, and remote JWKS HTTP requests use a 5 second default timeout and manual redirect handling. Set `providerRequestTimeoutMs` on `createOidcVaultMiddleware(...)` to a positive integer number of milliseconds if your provider needs a different bound.
616
- - Provider response parse errors return sanitized client messages; oversized or malformed provider bodies are not returned to callers.
617
- - If manual endpoints are configured, `issuer` is still required so ID and logout tokens are issuer-bound.
618
- - Token responses must include `token_type: Bearer`; `expires_in`, when present, must be a finite non-negative integer.
1484
+ - `OidcVaultSession.expiresAt`, assigned by `sessionTtlMs`, application code, or store policy, is an explicit vault-session expiry in epoch milliseconds and remains enforced by store providers.
1485
+ - With `issuer` and `clientId` but no endpoint settings, discovery is used and the discovered issuer must exactly equal the configured issuer (only configured surrounding whitespace is trimmed; `/tenant`, `/tenant/`, and `/tenant//` are distinct identifiers).
1486
+ - Provider discovery metadata and remote JWKS resolvers are cached in bounded process-wide maps; these keys are intended to come from static middleware configuration, not request input. Discovery fetches are isolated by `(issuer, providerRequestTimeoutMs)` so differing instance policies never inherit each other's deadline, while settled successful metadata is additionally shared across timeouts for reuse. JWKS resolvers are isolated by `(jwks_uri, providerRequestTimeoutMs)` because JOSE fixes the fetch timeout at creation.
1487
+ - Successful discovery entries are reused for up to 10 minutes and both discovery and JWKS resolver maps retain at most 32 entries with oldest-entry eviction. Failed discovery requests evict only the owning policy entry so a later request can retry. Timeout options are validated before any cache lookup, so cached entries cannot bypass option validation.
1488
+ - Discovery, token, UserInfo, and remote JWKS HTTP requests use a 5 second default deadline covering response headers plus complete success/error body consumption; stalled or slow bodies fail with sanitized endpoint-specific timeout errors. Cancellation is attempted promptly without awaiting its promise, so an uncooperative custom stream cannot hold up error delivery through pending cleanup. Request completion does not guarantee completed resource cleanup; the hanging-cancellation evidence uses custom streams, with no native-undici remote exploit established. Upstream redirects are never followed. Set `providerRequestTimeoutMs` to a positive integer number of milliseconds to change the bound. JWKS documents additionally enforce 1 MiB and 100-key limits.
1489
+ - Pre-header network rejection and mid-body transport reset return HTTP 502 with `OIDC_VAULT_DISCOVERY_FAILED`, `OIDC_VAULT_TOKEN_REQUEST_FAILED`, or `OIDC_VAULT_USERINFO_FAILED` and message `OIDC provider request failed.` JWKS transport failures use `OIDC_VAULT_JWKS_FAILED`; JOSE timeouts retain `ERR_JWKS_TIMEOUT`. Discovery success-body timeout/size/JSON failures retain `OIDC_VAULT_DISCOVERY_INVALID`. Original transport diagnostics are privately available as `hooks.onError` context `error.cause` (narrow the unknown error before reading it); they are not browser payload fields.
1490
+ - Provider response parse errors return sanitized client messages; oversized or malformed provider bodies are not returned to callers. Discovery, token, and UserInfo JSON bodies must be non-null, non-array objects; valid non-object JSON (`null`, arrays, strings, booleans, numbers) is a controlled 502 provider error.
1491
+ - Non-success token/UserInfo responses always surface 502 with a stable code/message (`OIDC_VAULT_TOKEN_REQUEST_FAILED` / `OIDC_VAULT_USERINFO_FAILED`) regardless of JSON versus HTML bodies and without leaking body content or the upstream status. Upstream redirects are never followed, so a rejected 302 never becomes a browser-facing 3xx.
1492
+ - If manual endpoints are configured, manual endpoints are used and discovery is not performed; `issuer` is still required so ID and logout tokens are issuer-bound against the exact configured identifier.
1493
+ - Token responses must include `token_type: Bearer`; `expires_in`, when present, must be a finite non-negative integer. Present `access_token`/`id_token`/`refresh_token` fields must be non-empty strings and a present `scope` must be a string; malformed present fields are rejected rather than treated as omissions.
1494
+ - Callback (authorization-code) responses additionally require `id_token` and `refresh_token`; no session or exchange code is persisted until all provider checks pass. The callback destination (transaction `returnTo` or `frontendRedirectUri`) is validated before any provider call or durable session/code creation and fails with `500 OIDC_VAULT_MISSING_FRONTEND_REDIRECT_URI` when neither is configured, so a missing destination cannot strand credentials.
619
1495
  - ID tokens must include `sub`, `exp`, and `iat`; `azp` must match `clientId` when present and is required for multi-audience ID tokens.
620
- - UserInfo responses must include a `sub` matching the verified ID-token subject before claims are merged into the session user.
621
- - Refresh responses may omit `id_token`; in that case, the middleware keeps the existing verified ID token and identity claims without requiring that original ID token to still be current. If refresh returns a new `id_token`, its `sub` must match the current session subject.
1496
+ - UserInfo responses must be objects including a `sub` matching the verified ID-token subject before claims are merged into the session user; JSON `null` never bypasses the subject check.
1497
+ - Refresh responses may omit `id_token`, `refresh_token`, `access_token`, and `scope`; omitted fields retain their current session values (omitted `id_token` keeps the existing verified identity without requiring the original ID token to still be current). If refresh returns a new `id_token`, its `sub` must match the current session subject; no rotation happens until all provider checks pass.
1498
+ - Refresh profile precedence: fresh verified ID claims are the base when a new `id_token` is present, freshly fetched matching UserInfo overlays whichever base applies, and the retained profile is used only when no new `id_token` arrives (fresh UserInfo still overlays the retained base per key). Retained values are never merged over fresh claims and are never treated as fresh UserInfo. Claims absent from the fresh sources are dropped when fresh identity arrives, so removed provider claims disappear; keep application custom attributes in `session.metadata`, not in `user`, because application-added `user` keys are not carried forward across a fresh identity refresh.
622
1499
  - `backendOrigin` is the public origin registered with your OIDC provider for the backend callback URI. The middleware normalizes it to an origin and uses it for `/callback` redirect URIs instead of trusting request `Host` headers.
623
- - `frontendRedirectUri` is the default browser return target after the backend completes the upstream callback.
624
- - `postLogoutRedirectUri` is optional. When configured, it must be an absolute HTTP(S) URL registered with the OIDC provider for post-logout redirects. It may be hosted on a different origin from `frontendRedirectUri` when that exact URL is provider-registered.
1500
+ - `frontendRedirectUri` is the default browser return target after the backend completes the upstream callback. It stays optional at middleware creation because non-callback routes do not need it, but the callback fails fast before durable state when neither the transaction `returnTo` nor this value is configured.
1501
+ - `postLogoutRedirectUri` is optional. When configured, it must be an absolute HTTP(S) URL registered with the OIDC provider for post-logout redirects. It may be hosted on a different origin from `frontendRedirectUri` when that exact URL is provider-registered. It is only consulted for redirected logout (`redirect: true`).
1502
+ - After original identity and any required proof/nonce/replay checks, local logout (`redirect` unset or `false`) never contacts the provider: it revokes the local session lineage, clears the cookie under cookie transport, delivers `onLogout` for a live session, and returns `200 { loggedOut: true }`. Redirected live logout (`redirect: true`) commits the same local outcome before attempting an upstream end-session redirect. Discovery errors are reported through `onError`; errors or an absent `endSessionEndpoint` fall back to local `200 { loggedOut: true }`. Alias-only logout authenticates built-in revocation context and deletes the target lineage without live-session hooks/upstream work. No live target returns idempotent local success without deletion.
625
1503
  - backchannel logout revokes local sessions by upstream `sid` when available, otherwise by `sub`
1504
+ - Every vault route response carries `Cache-Control: no-store` (login/callback/logout redirects, exchange/refresh/logout/backchannel JSON, and error JSON including body-parser errors) so caches do not retain session/access credentials, one-time exchange codes, or authorization redirects. Only `no-store` is emitted: legacy `Pragma`/`Expires` add no protection once `no-store` is present, and no `Referrer-Policy` is set because redirect targets intentionally expose protocol-required values (provider authorization URL, frontend `?code=`, upstream `id_token_hint`) to the navigation target. This does not clear browser history, disable reverse-proxy request logging, strip `?code=` from frontend URLs/history (the frontend must still clean up the callback URL, e.g. `history.replaceState`), or hide intentional provider redirect exposure. Verify with `curl -i` (expect `Cache-Control: no-store` on `GET /auth/oidc/login`, `POST /auth/oidc/exchange`, `POST /auth/oidc/refresh`, and `POST /auth/oidc/logout`) or assert `response.headers['cache-control'] === 'no-store'` in integration tests under both transports.
626
1505
 
627
1506
  ## Config Helpers
628
1507
 
@@ -634,10 +1513,14 @@ const config = resolveOidcVaultConfigFromEnv(process.env);
634
1513
 
635
1514
  Resolution behavior:
636
1515
 
637
- - if only `OIDC_ISSUER` is set, discovery mode resolves the provider endpoints and validates that the discovered issuer matches
638
- - if endpoint-specific env vars are set, manual mode requires `OIDC_ISSUER`, `OIDC_AUTHORIZATION_ENDPOINT`, `OIDC_TOKEN_ENDPOINT`, and `OIDC_JWKS_URI`
1516
+ - the issuer identifier is syntax-validated (absolute http/https URL without userinfo, query, or fragment; `http` is accepted for local-test providers) but otherwise preserved exactly after surrounding-whitespace trimming: no trailing slash is added and `/tenant`, `/tenant/`, and `/tenant//` remain distinct
1517
+ - `OIDC_CLIENT_ID` is always required; with `OIDC_ISSUER` and no endpoint settings, discovery resolves endpoints and requires exact discovered-issuer equality
1518
+ - any nonempty endpoint (`authorizationEndpoint`, `tokenEndpoint`, `jwksUri`, `userInfoEndpoint`, or `endSessionEndpoint`) selects manual mode with no discovery; this includes `OIDC_USERINFO_ENDPOINT` and `OIDC_END_SESSION_ENDPOINT`. Manual mode requires `issuer`, `authorizationEndpoint`, `tokenEndpoint`, and `jwksUri`, plus `clientId`. Optional endpoints are not partial discovery overrides
1519
+ - undefined, empty, and whitespace-only config/env strings are absent after trimming; complete manual configuration preserves valid optional endpoints
639
1520
  - `OIDC_SCOPES` defaults to `openid email profile`
640
1521
 
1522
+ Discovery may omit `userinfo_endpoint` and `end_session_endpoint`. If present, each must be a nonempty absolute HTTP(S) URL string. Null, arrays, objects, numbers, booleans, blank strings, malformed URLs, and non-HTTP(S) URLs invalidate metadata with HTTP 502 / `OIDC_VAULT_DISCOVERY_INVALID`, identifying the field without echoing its value. Failed metadata is evicted so later requests can fetch corrected metadata; only validated successes are shared across timeout policies. During redirected logout, discovery errors instead reach `onError` while local revocation still succeeds; local-only logout does not discover metadata.
1523
+
641
1524
  ### Manual endpoint mode
642
1525
 
643
1526
  If your provider metadata is not discoverable from `OIDC_ISSUER`, configure the endpoints directly.
@@ -688,10 +1571,12 @@ In manual mode, the minimum required config is:
688
1571
 
689
1572
  `userInfoEndpoint` and `endSessionEndpoint` are optional but recommended when your provider supports them.
690
1573
 
691
- ## Local Access Token Example
1574
+ ## Local Access Token Example (unbound Bearer)
692
1575
 
693
1576
  The middleware can return a local backend access token during `exchange` and `refresh` by providing a `tokenIssuer`.
694
1577
 
1578
+ This is the default **unbound** example. For bound JWTs use the [complete DPoP server](#complete-dpop-server). Do not put an opaque vault handle into JWT claims if cookie mode is intended to keep it HttpOnly; `sid` below is suitable only when you deliberately expose that handle in body transport.
1579
+
695
1580
  ```ts
696
1581
  import express from 'express';
697
1582
  import { SignJWT } from 'jose';
@@ -699,7 +1584,27 @@ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
699
1584
  import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
700
1585
 
701
1586
  const app = express();
702
- const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
1587
+
1588
+ // Fail startup when no suitably strong signing key is configured. There is no
1589
+ // public fallback: `APP_JWT_SECRET` must be a strong random value that encodes
1590
+ // to at least 32 bytes for HS256.
1591
+ const requireSigningKey = (raw: string | undefined): Uint8Array => {
1592
+ if (!raw) {
1593
+ throw new Error('APP_JWT_SECRET must be set to a strong random value at least 32 bytes long.');
1594
+ }
1595
+
1596
+ const key = new TextEncoder().encode(raw);
1597
+
1598
+ if (key.length < 32) {
1599
+ throw new Error('APP_JWT_SECRET must decode to at least 32 bytes for HS256 local access tokens.');
1600
+ }
1601
+
1602
+ return key;
1603
+ };
1604
+
1605
+ const jwtSecret = requireSigningKey(process.env.APP_JWT_SECRET);
1606
+ const localTokenIssuer = 'https://api.example.com';
1607
+ const localTokenAudience = 'api-audience';
703
1608
 
704
1609
  app.use(
705
1610
  createOidcVaultMiddleware({
@@ -721,6 +1626,8 @@ app.use(
721
1626
  scope: session.scope,
722
1627
  })
723
1628
  .setProtectedHeader({ alg: 'HS256' })
1629
+ .setIssuer(localTokenIssuer)
1630
+ .setAudience(localTokenAudience)
724
1631
  .setIssuedAt()
725
1632
  .setExpirationTime('15m')
726
1633
  .sign(jwtSecret);
@@ -739,12 +1646,37 @@ app.use(
739
1646
  That local token is separate from the upstream IdP access token:
740
1647
 
741
1648
  - the upstream refresh token stays in the server-side vault
742
- - the frontend receives only the app-issued access token and the opaque `sessionId`
1649
+ - the frontend receives local token fields and the session's `user` profile; body transport also includes the opaque `sessionId`, while cookie transport omits it
743
1650
  - the app-issued access token can contain only the claims your backend APIs actually need
744
1651
 
1652
+ ### Local issuer result contract
1653
+
1654
+ `tokenIssuer.issue` must resolve to a non-null, non-array object with:
1655
+
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.
1659
+
1660
+ 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
+
1662
+ For a bound request, `IssueTokenInput.deviceBinding?: Readonly<OidcVaultVerifiedDpopBinding>` supplies the authoritative frozen `{ type: 'dpop', jkt, alg }` context. Use its `jkt` for the local JWT's `cnf.jkt`; do not infer binding from mutable user/profile data. Core checks this trusted issuer's payload contract by decoding its result, while APIs independently verify the token's signature, issuer, audience, and expiry. Local signing may still use HS256; DPoP proofs use the separately configured asymmetric profile.
1663
+
1664
+ Malformed results return HTTP 500 with `{"code":"OIDC_VAULT_INTERNAL_ERROR","message":"Unexpected OIDC vault error."}` inside issuance rollback: the logical lineage is revoked and cookie transport clears its cookie instead of minting one. Exchange has already consumed its code; refresh has already contacted the provider and rotated the handle, and its success notification does not run. Correct the issuer and start a new login. Field-specific diagnostics are the original `hooks.onError` context `error` (narrow it before use); allowed-field getter exceptions also enter rollback.
1665
+
1666
+ This projection contains accidental result extensions. Issuers/hooks remain trusted code with request/response access; the issuer receives a detached session and plain profile/provider/metadata containers, while core retains its binding and original rollback lineage. Application profiles and deliberate secrets placed in allowed fields are not redacted.
1667
+
1668
+ ## Migration And Behavior Changes
1669
+
1670
+ - 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
+ - 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
+ - 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
+ - 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
+ - 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.
1675
+ - 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
+
745
1677
  ## Access Token Validation Middleware
746
1678
 
747
- The OIDC route/session middleware and the normal API bearer-token middleware are separate concerns.
1679
+ The OIDC route/session middleware and API access-token authentication are separate concerns.
748
1680
 
749
1681
  Use `createOidcVaultMiddleware(...)` for:
750
1682
 
@@ -758,33 +1690,62 @@ Use `createOidcVaultAccessTokenMiddleware(...)` for:
758
1690
 
759
1691
  - validating the app-issued local access token on protected API routes
760
1692
  - attaching authenticated auth context to `req.auth`
761
- - rejecting missing, malformed, invalid, or expired bearer tokens with `401`
1693
+ - rejecting missing, malformed, invalid, or expired access tokens with `401`
1694
+ - enforcing opt-in DPoP sender constraints before authenticated context or downstream work
1695
+
1696
+ `onAuthContext` is a pre-`next()` veto hook, not a post-commit notification:
1697
+ when it throws, downstream middleware never runs and `req.auth` is detached
1698
+ before the error response is sent. A valid token plus a failing hook never
1699
+ surfaces as an invalid-token `401`: a forwarded controlled package error keeps
1700
+ its own status/code/client message (only a `401` veto carries an authentication
1701
+ challenge; the default legacy path uses `Bearer`), while any other hook error becomes a sanitized `500
1702
+ OIDC_VAULT_AUTH_CONTEXT_FAILED` without leaking the original message. Pass
1703
+ `onError` to observe the original error object (extraction, token/proof validator,
1704
+ nonce/replay, or hook failure) for private server-side logs; it never affects the
1705
+ sanitized client response. The middleware snapshots error response data before
1706
+ the observer runs, detaches `req.auth` on failure, and restores security-owned
1707
+ token/confirmation/binding after mutable successful hooks. API responses carry
1708
+ `Cache-Control: no-store`; admitted replay reservations remain consumed after a veto.
762
1709
 
763
1710
  ```ts
764
1711
  import express from 'express';
765
- import { createOidcVaultAccessTokenMiddleware } from '@web-ts-toolkit/express-oidc-vault';
766
- import { jwtVerify } from 'jose';
1712
+ import {
1713
+ createOidcVaultAccessTokenMiddleware,
1714
+ createOidcVaultJwtAccessTokenValidator,
1715
+ } from '@web-ts-toolkit/express-oidc-vault';
767
1716
 
768
1717
  const app = express();
769
- const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
1718
+
1719
+ // Fail startup when no suitably strong signing key is configured. There is no
1720
+ // public fallback: `APP_JWT_SECRET` must be a strong random value that encodes
1721
+ // to at least 32 bytes for HS256.
1722
+ const requireSigningKey = (raw: string | undefined): Uint8Array => {
1723
+ if (!raw) {
1724
+ throw new Error('APP_JWT_SECRET must be set to a strong random value at least 32 bytes long.');
1725
+ }
1726
+
1727
+ const key = new TextEncoder().encode(raw);
1728
+
1729
+ if (key.length < 32) {
1730
+ throw new Error('APP_JWT_SECRET must decode to at least 32 bytes for HS256 local access tokens.');
1731
+ }
1732
+
1733
+ return key;
1734
+ };
1735
+
1736
+ const jwtSecret = requireSigningKey(process.env.APP_JWT_SECRET);
1737
+ const localTokenIssuer = 'https://api.example.com';
1738
+ const localTokenAudience = 'api-audience';
770
1739
 
771
1740
  app.get(
772
1741
  '/api/me',
773
1742
  createOidcVaultAccessTokenMiddleware({
774
- validator: {
775
- async validate(token) {
776
- const result = await jwtVerify(token, jwtSecret, {
777
- algorithms: ['HS256'],
778
- });
779
-
780
- return {
781
- subject: String(result.payload.sub),
782
- sessionId: typeof result.payload.sid === 'string' ? result.payload.sid : undefined,
783
- scope: typeof result.payload.scope === 'string' ? result.payload.scope : undefined,
784
- claims: result.payload as Record<string, unknown>,
785
- };
786
- },
787
- },
1743
+ validator: createOidcVaultJwtAccessTokenValidator({
1744
+ key: jwtSecret,
1745
+ issuer: localTokenIssuer,
1746
+ audience: localTokenAudience,
1747
+ algorithms: ['HS256'],
1748
+ }),
788
1749
  }),
789
1750
  (req, res) => {
790
1751
  res.json({
@@ -803,6 +1764,8 @@ Returned auth context shape:
803
1764
  - `req.auth.sessionId`
804
1765
  - `req.auth.scope`
805
1766
  - `req.auth.claims`
1767
+ - `req.auth.confirmation` (mandatory object/null in request-aware mode; absent for a legacy unbound JWT)
1768
+ - `req.auth.deviceBinding` (frozen `{ type: 'dpop', jkt, alg }` after bound proof/nonce/replay acceptance)
806
1769
 
807
1770
  The package augments Express request typing so `req.auth` is available without casting in TypeScript route handlers.
808
1771
 
@@ -816,7 +1779,21 @@ import {
816
1779
  createOidcVaultJwtAccessTokenValidator,
817
1780
  } from '@web-ts-toolkit/express-oidc-vault';
818
1781
 
819
- const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
1782
+ const requireSigningKey = (raw: string | undefined): Uint8Array => {
1783
+ if (!raw) {
1784
+ throw new Error('APP_JWT_SECRET must be set to a strong random value at least 32 bytes long.');
1785
+ }
1786
+
1787
+ const key = new TextEncoder().encode(raw);
1788
+
1789
+ if (key.length < 32) {
1790
+ throw new Error('APP_JWT_SECRET must decode to at least 32 bytes for HS256 local access tokens.');
1791
+ }
1792
+
1793
+ return key;
1794
+ };
1795
+
1796
+ const jwtSecret = requireSigningKey(process.env.APP_JWT_SECRET);
820
1797
 
821
1798
  app.get(
822
1799
  '/api/me',
@@ -845,12 +1822,123 @@ Default JWT claim mapping:
845
1822
  - `scope` -> `auth.scope`
846
1823
  - full verified payload -> `auth.claims`
847
1824
 
848
- If you need a custom mapping, pass `mapClaims(...)` to `createOidcVaultJwtAccessTokenValidator(...)`.
1825
+ If you need a custom mapping, pass `mapClaims(...)` to `createOidcVaultJwtAccessTokenValidator(...)`. The helper verifies the JWT first, snapshots its original `cnf` **before** mapping, and discards mapper-supplied `confirmation` and `deviceBinding`. Deleting/mutating `claims.cnf` or returning only a subject cannot remove the proof requirement. Supported `cnf` is exactly `{ jkt: '<canonical SHA-256 thumbprint>' }`; null, malformed values, and other confirmation methods are invalid tokens, never legacy unbound tokens. Construction copies secret/JWK/allowlist/audience containers and captures the mapper.
1826
+
1827
+ 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
+
1829
+ ### Request-aware DPoP APIs
1830
+
1831
+ **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.
1832
+
1833
+ ```ts
1834
+ import express from 'express';
1835
+ import { createClient } from 'redis';
1836
+ import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';
1837
+ import {
1838
+ createOidcVaultAccessTokenMiddleware,
1839
+ createOidcVaultJwtAccessTokenValidator,
1840
+ type OidcVaultApiDeviceBindingOptions,
1841
+ } from '@web-ts-toolkit/express-oidc-vault';
1842
+
1843
+ const redis = createClient({ url: process.env.REDIS_URL });
1844
+ redis.on('error', () => console.warn('OIDC vault Redis connection error.'));
1845
+ await redis.connect();
1846
+ const replayStore = createRedisOidcVaultStore({ client: redis, keyPrefix: 'app-auth' });
1847
+ const rawSecret = process.env.APP_JWT_SECRET;
1848
+ if (!rawSecret || Buffer.byteLength(rawSecret, 'utf8') < 32) {
1849
+ throw new Error('APP_JWT_SECRET must encode at least 32 random bytes.');
1850
+ }
1851
+ const validator = createOidcVaultJwtAccessTokenValidator({
1852
+ key: new TextEncoder().encode(rawSecret),
1853
+ issuer: 'https://api.example.com',
1854
+ audience: 'api-audience',
1855
+ algorithms: ['HS256'], // Local JWT signing; DPoP proofs remain asymmetric.
1856
+ mapClaims: (claims) => ({ subject: String(claims.sub), claims }),
1857
+ });
1858
+ const deviceBinding: OidcVaultApiDeviceBindingOptions = {
1859
+ mode: 'optional', // 'required' rejects genuinely unbound JWTs.
1860
+ publicOrigin: 'https://api.example.com',
1861
+ publicPathPrefix: '/public', // Only if a proxy stripped this external prefix.
1862
+ replayNamespace: 'app-api-v1', // Same static value on every instance/route in this space.
1863
+ replayStore,
1864
+ algorithms: ['ES256'], // Default. Explicit PS256/RS256 require RSA 2048–4096 bits.
1865
+ proofMaxAgeSeconds: 60, // Default; integer 1–300.
1866
+ clockSkewSeconds: 5, // Default; integer 0–30.
1867
+ // nonce: { secret: sharedRandomNonceBytes, lifetimeSeconds: 60 },
1868
+ };
1869
+ const app = express();
1870
+ app.get('/api/me', createOidcVaultAccessTokenMiddleware({ validator, deviceBinding }), (req, res) => {
1871
+ res.json({ subject: req.auth?.subject, deviceBinding: req.auth?.deviceBinding });
1872
+ });
1873
+ ```
1874
+
1875
+ Here the proof target is `https://api.example.com/public/api/me`. With no stripped prefix, omit `publicPathPrefix`. Origins are static HTTPS origins (loopback HTTP is allowed for development), without userinfo/path/query/fragment. The target comes from that pinned origin plus the configured prefix and `req.originalUrl` pathname: Host, Forwarded, X-Forwarded-\* and Express `trust proxy` never choose it. Comparison strips query/fragment, normalizes scheme/host/default port/dot segments/unreserved escapes, uppercases remaining percent escapes, and preserves reserved escapes such as `%2F`. Origin-form `//host/path` stays a path on the pinned origin. Keep proxy mount paths consistent with this configuration.
1876
+
1877
+ | API binding configuration | Verified token | Presentation |
1878
+ | ------------------------- | -------------- | --------------------------------------------------------------------------------------------------- |
1879
+ | Omitted | Unbound | Existing Bearer path; exactly `validate(token)` with one argument. |
1880
+ | Omitted | Known bound | Refused with `OIDC_VAULT_DPOP_REQUIRED`. |
1881
+ | Optional | Unbound | Bearer accepted; a later proof never upgrades it. DPoP scheme is invalid. |
1882
+ | Required | Unbound | Refused with `OIDC_VAULT_DEVICE_BINDING_REQUIRED`. |
1883
+ | Optional/required | Bound | Exact DPoP presentation plus one valid proof from the original key. Bearer fails even with a proof. |
1884
+
1885
+ Binding-enabled construction requires `validator.validateWithRequest`, invoked for both schemes with `{ token, scheme: 'Bearer' | 'DPoP', req: Request }`. Custom JWT/introspection adapters return `OidcVaultRequestAwareAccessTokenValidationResult` with **mandatory** `confirmation: { jkt } | null`, derived from verified original data. Null means genuinely unbound; omission/malformed confirmation fails closed. Adapters are trusted to report binding faithfully. Middleware captures credential, raw proof count/value, method and path before an asynchronous adapter can mutate the request, and never accepts adapter-supplied `token`/`deviceBinding` as proof authority.
1886
+
1887
+ The wire request is:
1888
+
1889
+ ```http
1890
+ Authorization: DPoP <local-access-token>
1891
+ DPoP: <fresh-signed-proof-jwt>
1892
+ ```
1893
+
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.
1895
+
1896
+ 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
+
1898
+ API replay uses the shared `['api', normalizedPublicOrigin, replayNamespace]` protection space; paths, tokens and instance IDs do not partition it. Use one shared memory-store object only for local single-process testing, or the shared Redis/MongoDB service for multiple processes. Every accepted proof costs an atomic write; configure matching windows/namespaces/capacity/nonce secrets and synchronized clocks on all instances. No implicit replay fallback exists and later hook/route failures never release reservations.
1899
+
1900
+ #### API errors and nonce retry
1901
+
1902
+ For the default ES256 policy (`algs` lists the configured allowlist in order):
1903
+
1904
+ | Condition | HTTP / code | `WWW-Authenticate` |
1905
+ | ---------------------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------- |
1906
+ | No credentials, optional | 401 `OIDC_VAULT_MISSING_ACCESS_TOKEN` | `Bearer, DPoP algs="ES256"` |
1907
+ | No credentials, required | 401 `OIDC_VAULT_MISSING_ACCESS_TOKEN` | `DPoP algs="ES256"` |
1908
+ | Invalid Bearer token / required unbound Bearer | 401 `OIDC_VAULT_INVALID_ACCESS_TOKEN` / `OIDC_VAULT_DEVICE_BINDING_REQUIRED` | `Bearer error="invalid_token"` |
1909
+ | Invalid DPoP token / DPoP presentation of an unbound token | 401 `OIDC_VAULT_INVALID_ACCESS_TOKEN` | `DPoP error="invalid_token", algs="ES256"` |
1910
+ | Bound downgrade / missing proof | 401 `OIDC_VAULT_DPOP_REQUIRED` | `DPoP error="invalid_dpop_proof", algs="ES256"` |
1911
+ | Invalid/duplicate/oversized/stale/wrong-key/replayed proof | 401 `OIDC_VAULT_INVALID_DPOP_PROOF` | `DPoP error="invalid_dpop_proof", algs="ES256"` |
1912
+ | Otherwise-valid proof needs a nonce | 401 `OIDC_VAULT_USE_DPOP_NONCE` | `DPoP error="use_dpop_nonce", algs="ES256"` |
1913
+ | Replay service error/capacity | 503 `OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE` | Absent |
1914
+
1915
+ JSON stays `{ code, message }` with fixed messages: `Missing access token.`, `A device-bound login is required.`, `DPoP authentication is required.`, `Access token validation failed.`, `DPoP proof validation failed.`, `A fresh DPoP nonce is required.`, or `DPoP replay protection is unavailable.` No `error_description`, raw claim/key/URL or provider diagnostic is emitted. Non-401 failures have no authentication challenge. The omitted-binding legacy parser/errors/Bearer challenge remain compatible.
1916
+
1917
+ Nonce is off by default. Enable it with a shared random `Uint8Array` secret of at least 32 bytes and optional lifetime 1–300 seconds (default 60), copied at construction. An otherwise-valid proof with a missing/expired/wrong nonce receives exactly one bounded `DPoP-Nonce` header before replay reservation. Cache it per protection space/key and retry **once** with fresh JTI/iat/signature and the nonce; stop after a repeated challenge. Any authentic live issued nonce is valid, including older parallel challenges. A nonce does not excuse stale proof time or replay. Cross-origin browser CORS must allow `Authorization` and `DPoP`, and expose `DPoP-Nonce` and `WWW-Authenticate` when read by the client. The [private browser example](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md) implements persistent keys and bounded fetch/refresh retries.
1918
+
1919
+ ### Fixed DPoP and recognition errors
1920
+
1921
+ Vault routes and API middleware return sanitized `{ code, message }`. Invalid proof details are deliberately combined, including wrong key/target/ath and replay. For client handling, use the code/status/challenge rather than private diagnostics:
1922
+
1923
+ | HTTP | Code | Fixed message |
1924
+ | ------------------- | ------------------------------------------ | -------------------------------------------------- |
1925
+ | 401 | `OIDC_VAULT_MISSING_ACCESS_TOKEN` | `Missing access token.` (binding-enabled API only) |
1926
+ | 401 | `OIDC_VAULT_DEVICE_BINDING_REQUIRED` | `A device-bound login is required.` |
1927
+ | 401 | `OIDC_VAULT_DPOP_REQUIRED` | `DPoP authentication is required.` |
1928
+ | 401 | `OIDC_VAULT_INVALID_DPOP_PROOF` | `DPoP proof validation failed.` |
1929
+ | 400 | `OIDC_VAULT_INVALID_BROWSER_BINDING` | `Login browser binding validation failed.` |
1930
+ | 415 | `OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE` | `Login initiation requires a JSON request body.` |
1931
+ | 400 vault / 401 API | `OIDC_VAULT_USE_DPOP_NONCE` | `A fresh DPoP nonce is required.` |
1932
+ | 503 | `OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE` | `DPoP replay protection is unavailable.` |
1933
+ | 400 | `OIDC_VAULT_INVALID_FINGERPRINT` | `Fingerprint signal is invalid.` |
1934
+ | 403 | `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` | `Browser recognition changed; sign in again.` |
1935
+
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`.
849
1937
 
850
1938
  Recommended separation:
851
1939
 
852
1940
  - keep login/session lifecycle in `createOidcVaultMiddleware(...)`
853
- - keep normal API bearer validation in `createOidcVaultAccessTokenMiddleware(...)`
1941
+ - keep normal API Bearer/DPoP validation in `createOidcVaultAccessTokenMiddleware(...)`
854
1942
  - keep authorization decisions outside the validator middleware
855
1943
 
856
1944
  ## Hook Examples
@@ -860,11 +1948,43 @@ Hooks let the app observe or extend the core OIDC flow without forking the middl
860
1948
  ### Audit and user provisioning hooks
861
1949
 
862
1950
  ```ts
1951
+ import { createHmac } from 'node:crypto';
863
1952
  import express from 'express';
864
1953
  import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
865
1954
  import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
866
1955
 
867
1956
  const app = express();
1957
+ const auditKey = new TextEncoder().encode(process.env.APP_AUDIT_KEY ?? '');
1958
+
1959
+ // Purpose-specific keyed fingerprint for audit logs. Never log the raw
1960
+ // refresh-session ID: it is a credential that redeems a new session.
1961
+ const fingerprintSessionId = (sessionId: string | undefined): string | undefined => {
1962
+ if (!sessionId || auditKey.length === 0) {
1963
+ return undefined;
1964
+ }
1965
+
1966
+ return createHmac('sha256', auditKey).update(sessionId, 'utf8').digest('hex').slice(0, 16);
1967
+ };
1968
+
1969
+ // Query-free route label. Never log `req.originalUrl`: callback and frontend
1970
+ // URLs can carry `code`, `state`, or tokens in the query string.
1971
+ const queryFreeRoute = (req: { method?: string; path?: string }): string =>
1972
+ `${req.method ?? 'UNKNOWN'} ${req.path ?? 'unknown'}`;
1973
+
1974
+ // Selected sanitized error fields. Never log the arbitrary error object or its
1975
+ // message: provider, store, and hook errors may carry secrets or token bodies.
1976
+ const sanitizeErrorForLog = (error: unknown): { code: string; status?: number } => {
1977
+ if (typeof error === 'object' && error !== null && 'code' in error) {
1978
+ const { code, status } = error as { code?: unknown; status?: unknown };
1979
+
1980
+ return {
1981
+ code: typeof code === 'string' ? code : 'UNKNOWN',
1982
+ ...(typeof status === 'number' ? { status } : {}),
1983
+ };
1984
+ }
1985
+
1986
+ return { code: 'UNKNOWN' };
1987
+ };
868
1988
 
869
1989
  app.use(
870
1990
  createOidcVaultMiddleware({
@@ -897,8 +2017,10 @@ app.use(
897
2017
  },
898
2018
  async onSessionRefreshed({ session, metadata }) {
899
2019
  console.log('OIDC session rotated', {
900
- previousSessionId: metadata?.previousSessionId,
901
- nextSessionId: session?.sessionId,
2020
+ previousSession: fingerprintSessionId(
2021
+ typeof metadata?.previousSessionId === 'string' ? metadata.previousSessionId : undefined,
2022
+ ),
2023
+ nextSession: fingerprintSessionId(session?.sessionId),
902
2024
  });
903
2025
  },
904
2026
  async onLogout({ session, metadata }) {
@@ -910,8 +2032,8 @@ app.use(
910
2032
  async onError({ error, route, req }) {
911
2033
  console.error('OIDC vault error', {
912
2034
  route,
913
- path: req.originalUrl,
914
- error,
2035
+ path: queryFreeRoute(req),
2036
+ ...sanitizeErrorForLog(error),
915
2037
  });
916
2038
  },
917
2039
  },
@@ -933,13 +2055,14 @@ Recommended hook usage:
933
2055
  - use `onLogout` to revoke local app state that depends on the session
934
2056
  - use `onError` for structured logging and alerting
935
2057
 
936
- Client error responses keep a stable `{ code, message }` shape and intentionally avoid returning raw provider, store, hook, token issuer, or access-token validator details. Use `onError` to observe the original error object for private server-side logs.
2058
+ Client error responses keep a stable `{ code, message }` shape and intentionally avoid returning raw provider, store, hook, token issuer, or access-token validator details. Use the core `hooks.onError` to observe the original error object for private server-side logs; the separate API middleware reports its original extraction/validator/proof/nonce/replay/hook errors through its own `onAuthContext`-sibling `onError` option.
937
2059
 
938
2060
  ## Security Checklist
939
2061
 
940
2062
  Use these defaults when deploying the package:
941
2063
 
942
2064
  - keep `sessionId` in `sessionStorage` and keep `accessToken` in memory only
2065
+ - in cookie mode keep the vault handle backend-only, including omitting it from browser-readable JWT/profile claims
943
2066
  - never store the upstream refresh token in the browser
944
2067
  - use HTTPS end-to-end for frontend, backend, and IdP communication
945
2068
  - set `backendOrigin` to the public backend origin registered with the provider; do not rely on request host or proxy headers for callback URL construction
@@ -952,7 +2075,13 @@ Use these defaults when deploying the package:
952
2075
  - when using cookie transport, rely on cookie credentials only for `refresh` and `logout`; do not send fallback body `sessionId` values
953
2076
  - when using cross-site cookie transport, send frontend requests with `credentials: 'include'`, enable credentialed CORS, use `SameSite=None; Secure`, and allow only known frontend origins via `trustedOrigins`
954
2077
  - keep cookie-authenticated CSRF protection fail-closed for every `SameSite` mode by requiring an `Origin` or valid `Referer` matching `backendOrigin` or `trustedOrigins`
2078
+ - include the temporary HttpOnly cookie on POST login/exchange in both transports; their Origin/Referer policy is independent of CORS and DPoP
955
2079
  - protect any app-issued local access token with a short lifetime, such as 5 to 15 minutes
2080
+ - enforce request-aware DPoP validation at every API accepting bound tokens, with a pinned public origin/path and shared replay service; never retry with the same proof or fall back to Bearer for a bound JWT
2081
+ - remember DPoP binds a browser profile/key, not a physical device; same-browser XSS can invoke the key, and method/URI proofs do not sign request bodies or query strings
2082
+ - persist only a non-extractable private CryptoKey/public JWK in IndexedDB and require fresh login on key loss; non-extractability is not XSS protection
2083
+ - keep logout/revocation and API revocation distinct: bound proof-authenticated logout revokes refresh lineage, while already-issued stateless JWTs survive until expiry unless your validator checks revocation
2084
+ - disclose opted-in fingerprint collection/retention; recognition is copyable, never PoP, and deterministic hashing is not anonymization
956
2085
  - treat upstream OAuth `expires_in`, local access-token lifetime, and vault-session expiry as separate policies
957
2086
  - use Redis or MongoDB, not the memory store, for production or multi-instance deployments
958
2087
  - monitor `onError` and other hooks so failed callback, refresh, and logout flows are visible in private server logs without returning raw provider, token, store, or hook errors to clients