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

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 +261 -51
  2. package/index.d.mts +300 -9
  3. package/index.d.ts +300 -9
  4. package/index.js +619 -194
  5. package/index.mjs +620 -195
  6. package/package.json +2 -2
package/README.md CHANGED
@@ -22,6 +22,15 @@ Current implementation includes:
22
22
  pnpm add @web-ts-toolkit/express-oidc-vault express
23
23
  ```
24
24
 
25
+ 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.
26
+
27
+ Use **named imports from the package root**. There is no default export or public subpath API.
28
+
29
+ ## Requirements
30
+
31
+ - 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`).
32
+ - 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.
33
+
25
34
  ## Frontend Storage Policy
26
35
 
27
36
  Default browser-side transport:
@@ -61,7 +70,7 @@ This is the default mode.
61
70
 
62
71
  This mode stores `sessionId` in a backend-managed cookie.
63
72
 
64
- - `exchange` sets the session cookie and does not need to return `sessionId` in the JSON body
73
+ - `exchange` sets the session cookie and omits `sessionId` from the JSON body
65
74
  - `refresh` reads the cookie, rotates the session, and updates the cookie
66
75
  - `logout` reads the cookie and clears it
67
76
  - `refresh` and `logout` require the cookie and reject body-only `sessionId` values
@@ -79,7 +88,7 @@ Available cookie options:
79
88
  - `cookie.path`
80
89
  - `trustedOrigins`: browser origins allowed to call cookie-authenticated `refresh` and `logout`; required when cross-site cookie transport is enabled
81
90
 
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.
91
+ `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
92
 
84
93
  Default cookie behavior:
85
94
 
@@ -88,7 +97,8 @@ Default cookie behavior:
88
97
  - `httpOnly`: `true`
89
98
  - `deploymentMode`: `same-origin`
90
99
  - `sameSite`: `lax` unless `deploymentMode` is `cross-site`
91
- - `secure`: `true` when `sameSite` resolves to `none` or `deploymentMode` is `cross-site`
100
+ - `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)
101
+ - `SameSite=None` is always serialized with `Secure` because browsers reject `SameSite=None` without it, even with explicit `secure: false`
92
102
 
93
103
  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
104
 
@@ -148,6 +158,7 @@ app.use(
148
158
  },
149
159
  frontendRedirectUri: 'https://frontend.example.com/callback',
150
160
  storeProvider,
161
+ sessionTtlMs: 8 * 60 * 60 * 1000, // Opt in to an eight-hour absolute session lifetime.
151
162
  }),
152
163
  );
153
164
  ```
@@ -158,29 +169,54 @@ Use the memory store for local development and tests. For production deployments
158
169
 
159
170
  ## Public Options And Defaults
160
171
 
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. |
172
+ | Option | Default | Contract |
173
+ | ------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
174
+ | `basePath` | `/auth/oidc` | Mount path for the OIDC router. Route paths listed in this README are relative to this value. |
175
+ | `backendOrigin` | required | Public backend origin registered with the OIDC provider. Callback redirect URIs are derived from this pinned origin, not request host headers. |
176
+ | `storeProvider` | required | Durable vault store provider. Use Redis or MongoDB for production and multi-instance deployments. |
177
+ | `config` | required provider values | Supply `issuer` and `clientId`, or use `resolveOidcVaultConfigFromEnv(process.env)`. Endpoint settings select manual mode; see Config Helpers. |
178
+ | `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`. |
179
+ | `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`. |
180
+ | `fetchUserInfo` | implementation default | When enabled, UserInfo claims are fetched and merged only after the `sub` matches the verified ID token subject. |
181
+ | `authorizationTransactionTtlMs` | `600000` | TTL for one-time authorization transactions created during login. |
182
+ | `exchangeCodeTtlMs` | `30000` | TTL for one-time local exchange codes returned to the frontend callback route. |
183
+ | `sessionTtlMs` | unset | Opt-in positive safe-integer lifetime in milliseconds from callback session creation. Hooks may shorten it; refresh never extends it. |
184
+ | `sessionTransport` | `body` | `body` returns and accepts JSON `sessionId`; `cookie` stores the session pointer in an `HttpOnly` cookie and rejects body-only refresh/logout IDs. |
185
+ | `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. |
186
+ | `trustedOrigins` | `[]` plus `backendOrigin` internally | Browser origins allowed to call cookie-authenticated `refresh` and `logout`. Required for cross-site cookie transport. |
187
+ | `requestBodyLimit` | `16kb` | Express JSON and URL-encoded parser limit for OIDC route bodies. Increase only for known provider backchannel logout token size needs. |
188
+ | `providerRequestTimeoutMs` | `5000` | Deadline per provider HTTP exchange (headers plus complete body). Cancellation is attempted without awaiting cleanup. Positive finite integer; validated before cache lookup. |
189
+ | `hooks` | unset | Pre-commit hooks can veto operations by throwing; post-commit notification hook failures are reported to `onError` without undoing committed state. |
190
+ | `tokenIssuer` | unset | Issues app-local access tokens for `exchange` and `refresh`. This lifetime is separate from upstream token and vault-session lifetimes. |
191
+
192
+ 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, 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.
193
+
194
+ ## Absolute Session Lifetime
195
+
196
+ 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.
197
+
198
+ 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:
199
+
200
+ ```ts
201
+ hooks: {
202
+ onBeforeSessionCreate({ session }) {
203
+ if (session?.expiresAt !== undefined) {
204
+ session.expiresAt = Math.min(session.expiresAt, session.createdAt + 60 * 60 * 1000);
205
+ }
206
+ },
207
+ },
208
+ ```
209
+
210
+ 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.
211
+
212
+ `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
213
 
180
214
  ## Frontend Integration Example
181
215
 
182
216
  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.
183
217
 
218
+ 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).
219
+
184
220
  ```ts
185
221
  type AuthState = {
186
222
  accessToken: string | null;
@@ -408,7 +444,7 @@ The middleware validates the `logout_token` against the provider JWKS and then r
408
444
  - upstream `sid` when present
409
445
  - otherwise `sub`
410
446
 
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.
447
+ 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
448
 
413
449
  Example request:
414
450
 
@@ -555,8 +591,10 @@ app.use(
555
591
  postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
556
592
  sessionTransport: 'cookie',
557
593
  cookie: {
594
+ // Host-only (no `domain`): the browser scopes the cookie to
595
+ // `api.example.com` and still sends it on credentialed cross-origin
596
+ // requests from `https://frontend.example.com`.
558
597
  deploymentMode: 'same-site',
559
- domain: '.example.com',
560
598
  secure: true,
561
599
  },
562
600
  trustedOrigins: ['https://frontend.example.com'],
@@ -568,6 +606,8 @@ app.use(
568
606
  );
569
607
  ```
570
608
 
609
+ 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.
610
+
571
611
  ## Main Exports
572
612
 
573
613
  - `createOidcVaultAccessTokenMiddleware(...)`
@@ -577,7 +617,9 @@ app.use(
577
617
  - `DEFAULT_AUTHORIZATION_TRANSACTION_TTL_MS`
578
618
  - `DEFAULT_EXCHANGE_CODE_TTL_MS`
579
619
  - `DEFAULT_OIDC_SCOPES`
620
+ - `DEFAULT_OIDC_VAULT_REQUEST_BODY_LIMIT`
580
621
  - `OIDC_VAULT_ROUTE_PATHS`
622
+ - `OIDC_VAULT_URL_ENCODED_PARAMETER_LIMIT`
581
623
  - `normalizeOidcVaultBasePath(...)`
582
624
  - `resolveOidcVaultConfig(...)`
583
625
  - `resolveOidcVaultConfigFromEnv(...)`
@@ -585,21 +627,54 @@ app.use(
585
627
  - `type OidcVaultHooks`
586
628
  - `type OidcVaultStoreProvider`
587
629
  - `type OidcVaultSession`
630
+ - `type OidcVaultConfig`
631
+ - `type OidcVaultSessionInput`
632
+ - `OidcVaultStoreConflictError`
633
+ - `type OidcVaultExchangeResult`
634
+ - `type OidcVaultLogoutResult`
588
635
  - `type OidcVaultAccessTokenValidator`
589
636
  - `type OidcVaultAuthenticatedRequest`
590
637
  - `type OidcVaultJwtAccessTokenValidatorOptions`
591
638
  - `type OidcVaultTokenIssuer`
639
+ - `type OidcVaultTokenIssueResult`
640
+ - `type OidcVaultProviderMetadata`
592
641
 
593
642
  ## Store Provider Contract
594
643
 
595
- The built-in memory, Redis, and MongoDB store packages share the same behavioral contract.
644
+ 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
645
 
597
- - `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are deliberate upserts keyed by `state`, `code`, and `sessionId`.
646
+ - `createAuthorizationTransaction` and `createExchangeCode` are deliberate upserts keyed by `state` and `code`.
647
+ - `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
648
  - 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.
649
+ - 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.
650
+ - Expiry timestamps are epoch milliseconds. Memory/MongoDB check `expiresAt <= now`; Redis uses server-owned key TTLs and server time for index cleanup, not the optional application clock. Preserve matching Redis TTLs/index scores on restore: reads do not independently audit payload expiry after externally altered TTLs. Backchannel logout JTI expiry must be finite and in the future relative to the store clock or the consume call returns `false` without storing the JTI.
651
+ - 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
652
  - `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.
653
+ - 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.
654
+ - 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).
655
+ - 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.
656
+ - 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.
657
+ - 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.
658
+ - 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.
659
+
660
+ ## Session Identity And Store Namespaces
661
+
662
+ Exchange, refresh, and logout of a **live session** compare every stored `provider.issuer` and `provider.clientId` that is not undefined against the resolved middleware configuration. 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.
663
+
664
+ 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.
665
+
666
+ 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.
667
+
668
+ For complete identity isolation, use separate store namespaces for **session/alias, exchange-code, and authorization-transaction records**. Live-session checks alone do not isolate shared namespaces: exchange consumes the one-time code before checking identity, so a rejected foreign exchange still spends the owner's code. When logout finds no live session, it still calls `deleteSession` through the stale-alias path without an identity check, which can revoke a foreign lineage in shared storage.
669
+
670
+ ## Known Browser And Concurrency Limits
671
+
672
+ - **Browser binding:** `state`, nonce, PKCE, and one-time codes do not bind login/callback/exchange completion to the initiating browser. A transferred callback/frontend URL can cause login/session swapping; a stolen unused exchange code can be redeemed by another browser in either transport. `exchange` has no source-origin check and accepts URL-encoded forms. CORS, `SameSite`, and `trustedOrigins` on cookie refresh/logout do not establish this missing binding.
673
+ - **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.
674
+ - **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.
675
+ - **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.
676
+
677
+ Browser-bound proofs (BOV-02-FU1), 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). The lifetime, identity, and response changes documented here do not implement those protocols.
603
678
 
604
679
  ## Key Integration Notes
605
680
 
@@ -608,21 +683,27 @@ The built-in memory, Redis, and MongoDB store packages share the same behavioral
608
683
  - `sessionId` should rotate on refresh.
609
684
  - The frontend should deduplicate concurrent refresh calls so only one refresh is in-flight at a time.
610
685
  - 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.
686
+ - `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.
687
+ - 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).
688
+ - 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.
689
+ - 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.
690
+ - 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.
691
+ - 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.
692
+ - 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.
693
+ - 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.
694
+ - 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.
695
+ - 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.
696
+ - 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
697
  - 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.
698
+ - 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.
699
+ - 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.
700
+ - 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
701
  - `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.
702
+ - `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.
703
+ - `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`).
704
+ - After live-session identity 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 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 }`. With no live session, logout attempts stale-alias deletion and returns local success without `onLogout`; an expired alias may no longer identify a live lineage.
625
705
  - backchannel logout revokes local sessions by upstream `sid` when available, otherwise by `sub`
706
+ - 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
707
 
627
708
  ## Config Helpers
628
709
 
@@ -634,10 +715,14 @@ const config = resolveOidcVaultConfigFromEnv(process.env);
634
715
 
635
716
  Resolution behavior:
636
717
 
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`
718
+ - 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
719
+ - `OIDC_CLIENT_ID` is always required; with `OIDC_ISSUER` and no endpoint settings, discovery resolves endpoints and requires exact discovered-issuer equality
720
+ - 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
721
+ - undefined, empty, and whitespace-only config/env strings are absent after trimming; complete manual configuration preserves valid optional endpoints
639
722
  - `OIDC_SCOPES` defaults to `openid email profile`
640
723
 
724
+ 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.
725
+
641
726
  ### Manual endpoint mode
642
727
 
643
728
  If your provider metadata is not discoverable from `OIDC_ISSUER`, configure the endpoints directly.
@@ -699,7 +784,27 @@ import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
699
784
  import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
700
785
 
701
786
  const app = express();
702
- const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
787
+
788
+ // Fail startup when no suitably strong signing key is configured. There is no
789
+ // public fallback: `APP_JWT_SECRET` must be a strong random value that encodes
790
+ // to at least 32 bytes for HS256.
791
+ const requireSigningKey = (raw: string | undefined): Uint8Array => {
792
+ if (!raw) {
793
+ throw new Error('APP_JWT_SECRET must be set to a strong random value at least 32 bytes long.');
794
+ }
795
+
796
+ const key = new TextEncoder().encode(raw);
797
+
798
+ if (key.length < 32) {
799
+ throw new Error('APP_JWT_SECRET must decode to at least 32 bytes for HS256 local access tokens.');
800
+ }
801
+
802
+ return key;
803
+ };
804
+
805
+ const jwtSecret = requireSigningKey(process.env.APP_JWT_SECRET);
806
+ const localTokenIssuer = 'https://api.example.com';
807
+ const localTokenAudience = 'api-audience';
703
808
 
704
809
  app.use(
705
810
  createOidcVaultMiddleware({
@@ -721,6 +826,8 @@ app.use(
721
826
  scope: session.scope,
722
827
  })
723
828
  .setProtectedHeader({ alg: 'HS256' })
829
+ .setIssuer(localTokenIssuer)
830
+ .setAudience(localTokenAudience)
724
831
  .setIssuedAt()
725
832
  .setExpirationTime('15m')
726
833
  .sign(jwtSecret);
@@ -739,9 +846,31 @@ app.use(
739
846
  That local token is separate from the upstream IdP access token:
740
847
 
741
848
  - the upstream refresh token stays in the server-side vault
742
- - the frontend receives only the app-issued access token and the opaque `sessionId`
849
+ - 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
850
  - the app-issued access token can contain only the claims your backend APIs actually need
744
851
 
852
+ ### Local issuer result contract
853
+
854
+ `tokenIssuer.issue` must resolve to a non-null, non-array object with:
855
+
856
+ - `accessToken`: nonempty opaque string, returned verbatim without trimming or a new whitespace policy;
857
+ - `expiresIn`: finite nonnegative safe-integer seconds, from 0 through `Number.MAX_SAFE_INTEGER`;
858
+ - `tokenType`: optional exact literal `'Bearer'`. Omitted/undefined stays absent in JSON; null, lowercase `'bearer'`, and other values are invalid.
859
+
860
+ 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.
861
+
862
+ 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.
863
+
864
+ This projection contains accidental result extensions. Issuers/hooks remain trusted code with mutable session/request/response access; application profiles and deliberate secrets placed in allowed fields are not redacted.
865
+
866
+ ## Migration And Behavior Changes
867
+
868
+ - 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.
869
+ - 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.
870
+ - 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.
871
+ - 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.
872
+ - 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.
873
+
745
874
  ## Access Token Validation Middleware
746
875
 
747
876
  The OIDC route/session middleware and the normal API bearer-token middleware are separate concerns.
@@ -760,13 +889,44 @@ Use `createOidcVaultAccessTokenMiddleware(...)` for:
760
889
  - attaching authenticated auth context to `req.auth`
761
890
  - rejecting missing, malformed, invalid, or expired bearer tokens with `401`
762
891
 
892
+ `onAuthContext` is a pre-`next()` veto hook, not a post-commit notification:
893
+ when it throws, downstream middleware never runs and `req.auth` is detached
894
+ before the error response is sent. A valid token plus a failing hook never
895
+ surfaces as an invalid-token `401`: an `OidcVaultHttpError` from the hook keeps
896
+ its own status/code/client message (only a `401` veto carries the `Bearer`
897
+ challenge), while any other hook error becomes a sanitized `500
898
+ OIDC_VAULT_AUTH_CONTEXT_FAILED` without leaking the original message. Pass
899
+ `onError` to observe the original bearer error object (extraction, validator,
900
+ or hook failure) for private server-side logs; it never affects the sanitized
901
+ client response.
902
+
763
903
  ```ts
764
904
  import express from 'express';
765
905
  import { createOidcVaultAccessTokenMiddleware } from '@web-ts-toolkit/express-oidc-vault';
766
906
  import { jwtVerify } from 'jose';
767
907
 
768
908
  const app = express();
769
- const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
909
+
910
+ // Fail startup when no suitably strong signing key is configured. There is no
911
+ // public fallback: `APP_JWT_SECRET` must be a strong random value that encodes
912
+ // to at least 32 bytes for HS256.
913
+ const requireSigningKey = (raw: string | undefined): Uint8Array => {
914
+ if (!raw) {
915
+ throw new Error('APP_JWT_SECRET must be set to a strong random value at least 32 bytes long.');
916
+ }
917
+
918
+ const key = new TextEncoder().encode(raw);
919
+
920
+ if (key.length < 32) {
921
+ throw new Error('APP_JWT_SECRET must decode to at least 32 bytes for HS256 local access tokens.');
922
+ }
923
+
924
+ return key;
925
+ };
926
+
927
+ const jwtSecret = requireSigningKey(process.env.APP_JWT_SECRET);
928
+ const localTokenIssuer = 'https://api.example.com';
929
+ const localTokenAudience = 'api-audience';
770
930
 
771
931
  app.get(
772
932
  '/api/me',
@@ -774,6 +934,8 @@ app.get(
774
934
  validator: {
775
935
  async validate(token) {
776
936
  const result = await jwtVerify(token, jwtSecret, {
937
+ issuer: localTokenIssuer,
938
+ audience: localTokenAudience,
777
939
  algorithms: ['HS256'],
778
940
  });
779
941
 
@@ -816,7 +978,21 @@ import {
816
978
  createOidcVaultJwtAccessTokenValidator,
817
979
  } from '@web-ts-toolkit/express-oidc-vault';
818
980
 
819
- const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');
981
+ const requireSigningKey = (raw: string | undefined): Uint8Array => {
982
+ if (!raw) {
983
+ throw new Error('APP_JWT_SECRET must be set to a strong random value at least 32 bytes long.');
984
+ }
985
+
986
+ const key = new TextEncoder().encode(raw);
987
+
988
+ if (key.length < 32) {
989
+ throw new Error('APP_JWT_SECRET must decode to at least 32 bytes for HS256 local access tokens.');
990
+ }
991
+
992
+ return key;
993
+ };
994
+
995
+ const jwtSecret = requireSigningKey(process.env.APP_JWT_SECRET);
820
996
 
821
997
  app.get(
822
998
  '/api/me',
@@ -860,11 +1036,43 @@ Hooks let the app observe or extend the core OIDC flow without forking the middl
860
1036
  ### Audit and user provisioning hooks
861
1037
 
862
1038
  ```ts
1039
+ import { createHmac } from 'node:crypto';
863
1040
  import express from 'express';
864
1041
  import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
865
1042
  import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
866
1043
 
867
1044
  const app = express();
1045
+ const auditKey = new TextEncoder().encode(process.env.APP_AUDIT_KEY ?? '');
1046
+
1047
+ // Purpose-specific keyed fingerprint for audit logs. Never log the raw
1048
+ // refresh-session ID: it is a credential that redeems a new session.
1049
+ const fingerprintSessionId = (sessionId: string | undefined): string | undefined => {
1050
+ if (!sessionId || auditKey.length === 0) {
1051
+ return undefined;
1052
+ }
1053
+
1054
+ return createHmac('sha256', auditKey).update(sessionId, 'utf8').digest('hex').slice(0, 16);
1055
+ };
1056
+
1057
+ // Query-free route label. Never log `req.originalUrl`: callback and frontend
1058
+ // URLs can carry `code`, `state`, or tokens in the query string.
1059
+ const queryFreeRoute = (req: { method?: string; path?: string }): string =>
1060
+ `${req.method ?? 'UNKNOWN'} ${req.path ?? 'unknown'}`;
1061
+
1062
+ // Selected sanitized error fields. Never log the arbitrary error object or its
1063
+ // message: provider, store, and hook errors may carry secrets or token bodies.
1064
+ const sanitizeErrorForLog = (error: unknown): { code: string; status?: number } => {
1065
+ if (typeof error === 'object' && error !== null && 'code' in error) {
1066
+ const { code, status } = error as { code?: unknown; status?: unknown };
1067
+
1068
+ return {
1069
+ code: typeof code === 'string' ? code : 'UNKNOWN',
1070
+ ...(typeof status === 'number' ? { status } : {}),
1071
+ };
1072
+ }
1073
+
1074
+ return { code: 'UNKNOWN' };
1075
+ };
868
1076
 
869
1077
  app.use(
870
1078
  createOidcVaultMiddleware({
@@ -897,8 +1105,10 @@ app.use(
897
1105
  },
898
1106
  async onSessionRefreshed({ session, metadata }) {
899
1107
  console.log('OIDC session rotated', {
900
- previousSessionId: metadata?.previousSessionId,
901
- nextSessionId: session?.sessionId,
1108
+ previousSession: fingerprintSessionId(
1109
+ typeof metadata?.previousSessionId === 'string' ? metadata.previousSessionId : undefined,
1110
+ ),
1111
+ nextSession: fingerprintSessionId(session?.sessionId),
902
1112
  });
903
1113
  },
904
1114
  async onLogout({ session, metadata }) {
@@ -910,8 +1120,8 @@ app.use(
910
1120
  async onError({ error, route, req }) {
911
1121
  console.error('OIDC vault error', {
912
1122
  route,
913
- path: req.originalUrl,
914
- error,
1123
+ path: queryFreeRoute(req),
1124
+ ...sanitizeErrorForLog(error),
915
1125
  });
916
1126
  },
917
1127
  },
@@ -933,7 +1143,7 @@ Recommended hook usage:
933
1143
  - use `onLogout` to revoke local app state that depends on the session
934
1144
  - use `onError` for structured logging and alerting
935
1145
 
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.
1146
+ 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 bearer middleware reports its original extraction/validator/hook errors through its own `onAuthContext`-sibling `onError` option.
937
1147
 
938
1148
  ## Security Checklist
939
1149