@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.
- package/README.md +1217 -88
- package/index.d.mts +697 -15
- package/index.d.ts +697 -15
- package/index.js +2494 -487
- package/index.mjs +2503 -497
- 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
|
|
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
|
|
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`
|
|
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
|
|
162
|
-
| ------------------------------- |
|
|
163
|
-
| `basePath` | `/auth/oidc`
|
|
164
|
-
| `backendOrigin` | required
|
|
165
|
-
| `storeProvider` | required
|
|
166
|
-
| `config` |
|
|
167
|
-
| `frontendRedirectUri` | unset
|
|
168
|
-
| `postLogoutRedirectUri` | unset
|
|
169
|
-
| `fetchUserInfo` |
|
|
170
|
-
| `authorizationTransactionTtlMs` | `600000`
|
|
171
|
-
| `exchangeCodeTtlMs` | `30000`
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
-
|
|
600
|
-
- Expiry timestamps are epoch milliseconds.
|
|
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.
|
|
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`,
|
|
612
|
-
-
|
|
613
|
-
- Provider discovery metadata and remote JWKS resolvers are cached in bounded process-wide maps
|
|
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
|
|
615
|
-
- Discovery, token, UserInfo, and remote JWKS HTTP requests use a 5 second default timeout
|
|
616
|
-
-
|
|
617
|
-
-
|
|
618
|
-
-
|
|
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
|
|
621
|
-
- Refresh responses may omit `id_token`;
|
|
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
|
-
-
|
|
638
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
766
|
-
|
|
1712
|
+
import {
|
|
1713
|
+
createOidcVaultAccessTokenMiddleware,
|
|
1714
|
+
createOidcVaultJwtAccessTokenValidator,
|
|
1715
|
+
} from '@web-ts-toolkit/express-oidc-vault';
|
|
767
1716
|
|
|
768
1717
|
const app = express();
|
|
769
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
901
|
-
|
|
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
|
|
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
|