@web-ts-toolkit/express-oidc-vault 0.47.1 → 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 +994 -75
- package/index.d.mts +423 -32
- package/index.d.ts +423 -32
- package/index.js +1972 -390
- package/index.mjs +1965 -384
- package/package.json +5 -3
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
|
|
|
@@ -86,7 +89,7 @@ Available cookie options:
|
|
|
86
89
|
- `cookie.secure`
|
|
87
90
|
- `cookie.domain`
|
|
88
91
|
- `cookie.path`
|
|
89
|
-
- `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
|
|
90
93
|
|
|
91
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: '/'`.
|
|
92
95
|
|
|
@@ -102,6 +105,8 @@ Default cookie behavior:
|
|
|
102
105
|
|
|
103
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.
|
|
104
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
|
+
|
|
105
110
|
Recommended frontend boot flow:
|
|
106
111
|
|
|
107
112
|
1. Read `sessionId` from `sessionStorage`.
|
|
@@ -121,13 +126,14 @@ If you use `sessionTransport: 'cookie'`, the frontend boot flow becomes simpler:
|
|
|
121
126
|
The core middleware exposes these endpoints under a configurable base path:
|
|
122
127
|
|
|
123
128
|
- `GET /auth/oidc/login`
|
|
129
|
+
- `POST /auth/oidc/login` when `deviceBinding` or `fingerprintRecognition` is configured (JSON initiation; see below)
|
|
124
130
|
- `GET /auth/oidc/callback`
|
|
125
131
|
- `POST /auth/oidc/exchange`
|
|
126
132
|
- `POST /auth/oidc/refresh`
|
|
127
133
|
- `POST /auth/oidc/logout`
|
|
128
134
|
- `POST /auth/oidc/backchannel-logout`
|
|
129
135
|
|
|
130
|
-
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.
|
|
131
137
|
|
|
132
138
|
Parser failures return a JSON client error before route handlers or store/provider hooks run. The stable error codes are:
|
|
133
139
|
|
|
@@ -137,7 +143,7 @@ Parser failures return a JSON client error before route handlers or store/provid
|
|
|
137
143
|
- `OIDC_VAULT_MALFORMED_REQUEST_BODY`
|
|
138
144
|
- `OIDC_VAULT_INVALID_REQUEST_BODY`
|
|
139
145
|
|
|
140
|
-
## Quick Start
|
|
146
|
+
## Quick Start (default unbound lifecycle)
|
|
141
147
|
|
|
142
148
|
```ts
|
|
143
149
|
import express from 'express';
|
|
@@ -169,27 +175,372 @@ Use the memory store for local development and tests. For production deployments
|
|
|
169
175
|
|
|
170
176
|
## Public Options And Defaults
|
|
171
177
|
|
|
172
|
-
| Option | Default
|
|
173
|
-
| ------------------------------- |
|
|
174
|
-
| `basePath` | `/auth/oidc`
|
|
175
|
-
| `backendOrigin` | required
|
|
176
|
-
| `storeProvider` | required
|
|
177
|
-
| `config` | required provider values
|
|
178
|
-
| `frontendRedirectUri` | unset
|
|
179
|
-
| `postLogoutRedirectUri` | unset
|
|
180
|
-
| `fetchUserInfo` |
|
|
181
|
-
| `authorizationTransactionTtlMs` | `600000`
|
|
182
|
-
| `exchangeCodeTtlMs` | `30000`
|
|
183
|
-
| `sessionTtlMs` | unset
|
|
184
|
-
| `sessionTransport` | `body`
|
|
185
|
-
| `cookie` | see cookie defaults above
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
193
544
|
|
|
194
545
|
## Absolute Session Lifetime
|
|
195
546
|
|
|
@@ -213,7 +564,362 @@ Omitting `sessionTtlMs` assigns no default session expiry and retains applicatio
|
|
|
213
564
|
|
|
214
565
|
## Frontend Integration Example
|
|
215
566
|
|
|
216
|
-
|
|
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.
|
|
217
923
|
|
|
218
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).
|
|
219
925
|
|
|
@@ -245,6 +951,21 @@ function setAuthState(payload: { accessToken?: string; sessionId: string }): voi
|
|
|
245
951
|
persistSessionId(payload.sessionId);
|
|
246
952
|
}
|
|
247
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
|
+
|
|
248
969
|
function clearAuthState(): void {
|
|
249
970
|
authState.accessToken = null;
|
|
250
971
|
persistSessionId(null);
|
|
@@ -262,7 +983,7 @@ async function exchangeCallbackCode(code: string): Promise<void> {
|
|
|
262
983
|
throw new Error('OIDC code exchange failed.');
|
|
263
984
|
}
|
|
264
985
|
|
|
265
|
-
setAuthState(await response.json());
|
|
986
|
+
setAuthState(readBodyCredentials(await response.json()));
|
|
266
987
|
}
|
|
267
988
|
|
|
268
989
|
async function refreshAuthState(): Promise<void> {
|
|
@@ -282,7 +1003,7 @@ async function refreshAuthState(): Promise<void> {
|
|
|
282
1003
|
throw new Error('OIDC refresh failed.');
|
|
283
1004
|
}
|
|
284
1005
|
|
|
285
|
-
setAuthState(await response.json());
|
|
1006
|
+
setAuthState(readBodyCredentials(await response.json()));
|
|
286
1007
|
}
|
|
287
1008
|
|
|
288
1009
|
async function ensureFreshAccessToken(): Promise<void> {
|
|
@@ -295,14 +1016,20 @@ async function ensureFreshAccessToken(): Promise<void> {
|
|
|
295
1016
|
await refreshPromise;
|
|
296
1017
|
}
|
|
297
1018
|
|
|
298
|
-
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
|
+
}
|
|
299
1026
|
const headers = new Headers(init.headers);
|
|
300
1027
|
|
|
301
1028
|
if (authState.accessToken) {
|
|
302
1029
|
headers.set('authorization', `Bearer ${authState.accessToken}`);
|
|
303
1030
|
}
|
|
304
1031
|
|
|
305
|
-
let response = await fetch(
|
|
1032
|
+
let response = await fetch(url, { ...init, headers, redirect: 'error', cache: 'no-store' });
|
|
306
1033
|
|
|
307
1034
|
if (response.status !== 401 || !authState.sessionId) {
|
|
308
1035
|
return response;
|
|
@@ -316,7 +1043,7 @@ async function fetchWithAuth(input: RequestInfo | URL, init: RequestInit = {}):
|
|
|
316
1043
|
retryHeaders.set('authorization', `Bearer ${authState.accessToken}`);
|
|
317
1044
|
}
|
|
318
1045
|
|
|
319
|
-
response = await fetch(
|
|
1046
|
+
response = await fetch(url, { ...init, headers: retryHeaders, redirect: 'error', cache: 'no-store' });
|
|
320
1047
|
return response;
|
|
321
1048
|
}
|
|
322
1049
|
|
|
@@ -353,7 +1080,7 @@ Recommended browser flow:
|
|
|
353
1080
|
|
|
354
1081
|
1. Redirect the user to `GET /auth/oidc/login` when they click login.
|
|
355
1082
|
2. On the frontend callback route, read `code` from the query string and call `exchangeCallbackCode(code)`.
|
|
356
|
-
3. Remove the `code` query parameter
|
|
1083
|
+
3. Remove the `code` query parameter with `history.replaceState` before asynchronous exchange to reduce URL/history exposure.
|
|
357
1084
|
4. Call `bootstrapAuth()` once during app startup so a reloaded tab can recover from `sessionStorage`.
|
|
358
1085
|
5. Use `fetchWithAuth(...)` or equivalent interceptor logic for normal API requests.
|
|
359
1086
|
|
|
@@ -376,6 +1103,17 @@ function setAuthState(payload: { accessToken?: string }): void {
|
|
|
376
1103
|
authState.accessToken = payload.accessToken ?? null;
|
|
377
1104
|
}
|
|
378
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
|
+
|
|
379
1117
|
function clearAuthState(): void {
|
|
380
1118
|
authState.accessToken = null;
|
|
381
1119
|
}
|
|
@@ -391,7 +1129,7 @@ async function refreshAuthState(): Promise<void> {
|
|
|
391
1129
|
throw new Error('OIDC refresh failed.');
|
|
392
1130
|
}
|
|
393
1131
|
|
|
394
|
-
setAuthState(await response.json());
|
|
1132
|
+
setAuthState(readCookieCredentials(await response.json()));
|
|
395
1133
|
}
|
|
396
1134
|
|
|
397
1135
|
async function ensureFreshAccessToken(): Promise<void> {
|
|
@@ -417,7 +1155,7 @@ async function exchangeCallbackCode(code: string): Promise<void> {
|
|
|
417
1155
|
throw new Error('OIDC code exchange failed.');
|
|
418
1156
|
}
|
|
419
1157
|
|
|
420
|
-
setAuthState(await response.json());
|
|
1158
|
+
setAuthState(readCookieCredentials(await response.json()));
|
|
421
1159
|
}
|
|
422
1160
|
```
|
|
423
1161
|
|
|
@@ -425,7 +1163,7 @@ For cross-origin cookie deployments, also remember:
|
|
|
425
1163
|
|
|
426
1164
|
- the frontend requests must use `credentials: 'include'`
|
|
427
1165
|
- the backend CORS policy must allow credentials
|
|
428
|
-
-
|
|
1166
|
+
- same-site subdomains can use Lax; truly cross-site requests need `SameSite=None; Secure` and browser cookie permission
|
|
429
1167
|
- set `trustedOrigins` so refresh and logout only accept requests from your frontend origin
|
|
430
1168
|
|
|
431
1169
|
## Backchannel Logout
|
|
@@ -512,6 +1250,7 @@ import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-re
|
|
|
512
1250
|
|
|
513
1251
|
const app = express();
|
|
514
1252
|
const redis = createClient({ url: process.env.REDIS_URL });
|
|
1253
|
+
redis.on('error', () => console.warn('OIDC vault Redis connection error.'));
|
|
515
1254
|
|
|
516
1255
|
await redis.connect();
|
|
517
1256
|
|
|
@@ -546,6 +1285,8 @@ const app = express();
|
|
|
546
1285
|
const mongo = new MongoClient(process.env.MONGODB_URI!);
|
|
547
1286
|
|
|
548
1287
|
await mongo.connect();
|
|
1288
|
+
const storeProvider = createMongoOidcVaultStore({ db: mongo.db('app-auth') });
|
|
1289
|
+
await storeProvider.ready();
|
|
549
1290
|
|
|
550
1291
|
app.use(
|
|
551
1292
|
createOidcVaultMiddleware({
|
|
@@ -558,9 +1299,7 @@ app.use(
|
|
|
558
1299
|
},
|
|
559
1300
|
frontendRedirectUri: 'https://frontend.example.com/callback',
|
|
560
1301
|
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
|
|
561
|
-
storeProvider
|
|
562
|
-
db: mongo.db('app-auth'),
|
|
563
|
-
}),
|
|
1302
|
+
storeProvider,
|
|
564
1303
|
}),
|
|
565
1304
|
);
|
|
566
1305
|
```
|
|
@@ -575,6 +1314,7 @@ import { createClient } from 'redis';
|
|
|
575
1314
|
|
|
576
1315
|
const app = express();
|
|
577
1316
|
const redis = createClient({ url: process.env.REDIS_URL });
|
|
1317
|
+
redis.on('error', () => console.warn('OIDC vault Redis connection error.'));
|
|
578
1318
|
|
|
579
1319
|
await redis.connect();
|
|
580
1320
|
|
|
@@ -624,21 +1364,40 @@ Only set `cookie.domain` (for example `.example.com`) as an advanced expansion w
|
|
|
624
1364
|
- `resolveOidcVaultConfig(...)`
|
|
625
1365
|
- `resolveOidcVaultConfigFromEnv(...)`
|
|
626
1366
|
- `type OidcVaultOptions`
|
|
1367
|
+
- `type OidcVaultDeviceBindingMode`, `OidcVaultDeviceBindingOptions`
|
|
1368
|
+
- `type OidcVaultDpopAlgorithm`, `OidcVaultDpopBinding`, `OidcVaultVerifiedDpopBinding`
|
|
1369
|
+
- `type OidcVaultDpopProofOptions`, `OidcVaultDpopNonceOptions`
|
|
1370
|
+
- `type OidcVaultTransactionCookieOptions`, `OidcVaultLoginInitiationInput`, `OidcVaultLoginInitiationResult`
|
|
1371
|
+
- `type OidcVaultFingerprintRecognitionOptions`
|
|
627
1372
|
- `type OidcVaultHooks`
|
|
628
1373
|
- `type OidcVaultStoreProvider`
|
|
629
1374
|
- `type OidcVaultSession`
|
|
630
1375
|
- `type OidcVaultConfig`
|
|
631
1376
|
- `type OidcVaultSessionInput`
|
|
632
1377
|
- `OidcVaultStoreConflictError`
|
|
1378
|
+
- `OidcVaultDpopReplayCapacityError`
|
|
1379
|
+
- `type OidcVaultDeviceBindingStoreProvider`
|
|
1380
|
+
- `type OidcVaultDpopReplayStore`
|
|
1381
|
+
- `type OidcVaultRecordBindingMatch`
|
|
1382
|
+
- `type OidcVaultSessionRevocationContext`
|
|
633
1383
|
- `type OidcVaultExchangeResult`
|
|
634
1384
|
- `type OidcVaultLogoutResult`
|
|
635
1385
|
- `type OidcVaultAccessTokenValidator`
|
|
1386
|
+
- `type OidcVaultRequestAwareAccessTokenValidator`
|
|
1387
|
+
- `type OidcVaultAccessTokenRequestInput`
|
|
1388
|
+
- `type OidcVaultAccessTokenConfirmation`
|
|
1389
|
+
- `type OidcVaultRequestAwareAccessTokenValidationResult`
|
|
1390
|
+
- `type OidcVaultApiDeviceBindingOptions`
|
|
1391
|
+
- `type OidcVaultAccessTokenMiddlewareOptions`
|
|
1392
|
+
- `type OidcVaultAuthContext`
|
|
636
1393
|
- `type OidcVaultAuthenticatedRequest`
|
|
637
1394
|
- `type OidcVaultJwtAccessTokenValidatorOptions`
|
|
638
1395
|
- `type OidcVaultTokenIssuer`
|
|
639
1396
|
- `type OidcVaultTokenIssueResult`
|
|
640
1397
|
- `type OidcVaultProviderMetadata`
|
|
641
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.
|
|
1400
|
+
|
|
642
1401
|
## Store Provider Contract
|
|
643
1402
|
|
|
644
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.
|
|
@@ -647,7 +1406,7 @@ The built-in memory, Redis, and MongoDB store packages share a portable subset w
|
|
|
647
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.
|
|
648
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.
|
|
649
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.
|
|
650
|
-
- Expiry timestamps are epoch milliseconds. Memory/MongoDB check `expiresAt <= now`; Redis uses server-owned key TTLs and server time
|
|
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.
|
|
651
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.
|
|
652
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.
|
|
653
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.
|
|
@@ -657,24 +1416,63 @@ The built-in memory, Redis, and MongoDB store packages share a portable subset w
|
|
|
657
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.
|
|
658
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.
|
|
659
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
|
+
|
|
660
1458
|
## Session Identity And Store Namespaces
|
|
661
1459
|
|
|
662
|
-
Exchange, refresh
|
|
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.
|
|
663
1461
|
|
|
664
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.
|
|
665
1463
|
|
|
666
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.
|
|
667
1465
|
|
|
668
|
-
For complete identity isolation, use separate store namespaces for **session/alias, exchange-code, and authorization-transaction records**.
|
|
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.
|
|
669
1467
|
|
|
670
1468
|
## Known Browser And Concurrency Limits
|
|
671
1469
|
|
|
672
|
-
- **
|
|
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.
|
|
673
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.
|
|
674
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.
|
|
675
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.
|
|
676
1474
|
|
|
677
|
-
Browser-bound proofs (BOV-02-FU1)
|
|
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.
|
|
678
1476
|
|
|
679
1477
|
## Key Integration Notes
|
|
680
1478
|
|
|
@@ -701,7 +1499,7 @@ Browser-bound proofs (BOV-02-FU1), cross-instance refresh reservation (BOV-03-FU
|
|
|
701
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.
|
|
702
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.
|
|
703
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`).
|
|
704
|
-
- After
|
|
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.
|
|
705
1503
|
- backchannel logout revokes local sessions by upstream `sid` when available, otherwise by `sub`
|
|
706
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.
|
|
707
1505
|
|
|
@@ -773,10 +1571,12 @@ In manual mode, the minimum required config is:
|
|
|
773
1571
|
|
|
774
1572
|
`userInfoEndpoint` and `endSessionEndpoint` are optional but recommended when your provider supports them.
|
|
775
1573
|
|
|
776
|
-
## Local Access Token Example
|
|
1574
|
+
## Local Access Token Example (unbound Bearer)
|
|
777
1575
|
|
|
778
1576
|
The middleware can return a local backend access token during `exchange` and `refresh` by providing a `tokenIssuer`.
|
|
779
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
|
+
|
|
780
1580
|
```ts
|
|
781
1581
|
import express from 'express';
|
|
782
1582
|
import { SignJWT } from 'jose';
|
|
@@ -855,17 +1655,20 @@ That local token is separate from the upstream IdP access token:
|
|
|
855
1655
|
|
|
856
1656
|
- `accessToken`: nonempty opaque string, returned verbatim without trimming or a new whitespace policy;
|
|
857
1657
|
- `expiresIn`: finite nonnegative safe-integer seconds, from 0 through `Number.MAX_SAFE_INTEGER`;
|
|
858
|
-
- `tokenType`:
|
|
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.
|
|
859
1659
|
|
|
860
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.
|
|
861
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
|
+
|
|
862
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.
|
|
863
1665
|
|
|
864
|
-
This projection contains accidental result extensions. Issuers/hooks remain trusted code with
|
|
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.
|
|
865
1667
|
|
|
866
1668
|
## Migration And Behavior Changes
|
|
867
1669
|
|
|
868
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.
|
|
869
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.
|
|
870
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.
|
|
871
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.
|
|
@@ -873,7 +1676,7 @@ This projection contains accidental result extensions. Issuers/hooks remain trus
|
|
|
873
1676
|
|
|
874
1677
|
## Access Token Validation Middleware
|
|
875
1678
|
|
|
876
|
-
The OIDC route/session middleware and
|
|
1679
|
+
The OIDC route/session middleware and API access-token authentication are separate concerns.
|
|
877
1680
|
|
|
878
1681
|
Use `createOidcVaultMiddleware(...)` for:
|
|
879
1682
|
|
|
@@ -887,23 +1690,29 @@ Use `createOidcVaultAccessTokenMiddleware(...)` for:
|
|
|
887
1690
|
|
|
888
1691
|
- validating the app-issued local access token on protected API routes
|
|
889
1692
|
- attaching authenticated auth context to `req.auth`
|
|
890
|
-
- 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
|
|
891
1695
|
|
|
892
1696
|
`onAuthContext` is a pre-`next()` veto hook, not a post-commit notification:
|
|
893
1697
|
when it throws, downstream middleware never runs and `req.auth` is detached
|
|
894
1698
|
before the error response is sent. A valid token plus a failing hook never
|
|
895
|
-
surfaces as an invalid-token `401`:
|
|
896
|
-
its own status/code/client message (only a `401` veto carries
|
|
897
|
-
challenge), while any other hook error becomes a sanitized `500
|
|
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
|
|
898
1702
|
OIDC_VAULT_AUTH_CONTEXT_FAILED` without leaking the original message. Pass
|
|
899
|
-
`onError` to observe the original
|
|
900
|
-
or hook failure) for private server-side logs; it never affects the
|
|
901
|
-
client response.
|
|
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.
|
|
902
1709
|
|
|
903
1710
|
```ts
|
|
904
1711
|
import express from 'express';
|
|
905
|
-
import {
|
|
906
|
-
|
|
1712
|
+
import {
|
|
1713
|
+
createOidcVaultAccessTokenMiddleware,
|
|
1714
|
+
createOidcVaultJwtAccessTokenValidator,
|
|
1715
|
+
} from '@web-ts-toolkit/express-oidc-vault';
|
|
907
1716
|
|
|
908
1717
|
const app = express();
|
|
909
1718
|
|
|
@@ -931,22 +1740,12 @@ const localTokenAudience = 'api-audience';
|
|
|
931
1740
|
app.get(
|
|
932
1741
|
'/api/me',
|
|
933
1742
|
createOidcVaultAccessTokenMiddleware({
|
|
934
|
-
validator: {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
return {
|
|
943
|
-
subject: String(result.payload.sub),
|
|
944
|
-
sessionId: typeof result.payload.sid === 'string' ? result.payload.sid : undefined,
|
|
945
|
-
scope: typeof result.payload.scope === 'string' ? result.payload.scope : undefined,
|
|
946
|
-
claims: result.payload as Record<string, unknown>,
|
|
947
|
-
};
|
|
948
|
-
},
|
|
949
|
-
},
|
|
1743
|
+
validator: createOidcVaultJwtAccessTokenValidator({
|
|
1744
|
+
key: jwtSecret,
|
|
1745
|
+
issuer: localTokenIssuer,
|
|
1746
|
+
audience: localTokenAudience,
|
|
1747
|
+
algorithms: ['HS256'],
|
|
1748
|
+
}),
|
|
950
1749
|
}),
|
|
951
1750
|
(req, res) => {
|
|
952
1751
|
res.json({
|
|
@@ -965,6 +1764,8 @@ Returned auth context shape:
|
|
|
965
1764
|
- `req.auth.sessionId`
|
|
966
1765
|
- `req.auth.scope`
|
|
967
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)
|
|
968
1769
|
|
|
969
1770
|
The package augments Express request typing so `req.auth` is available without casting in TypeScript route handlers.
|
|
970
1771
|
|
|
@@ -1021,12 +1822,123 @@ Default JWT claim mapping:
|
|
|
1021
1822
|
- `scope` -> `auth.scope`
|
|
1022
1823
|
- full verified payload -> `auth.claims`
|
|
1023
1824
|
|
|
1024
|
-
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`.
|
|
1025
1937
|
|
|
1026
1938
|
Recommended separation:
|
|
1027
1939
|
|
|
1028
1940
|
- keep login/session lifecycle in `createOidcVaultMiddleware(...)`
|
|
1029
|
-
- keep normal API
|
|
1941
|
+
- keep normal API Bearer/DPoP validation in `createOidcVaultAccessTokenMiddleware(...)`
|
|
1030
1942
|
- keep authorization decisions outside the validator middleware
|
|
1031
1943
|
|
|
1032
1944
|
## Hook Examples
|
|
@@ -1143,13 +2055,14 @@ Recommended hook usage:
|
|
|
1143
2055
|
- use `onLogout` to revoke local app state that depends on the session
|
|
1144
2056
|
- use `onError` for structured logging and alerting
|
|
1145
2057
|
|
|
1146
|
-
Client error responses keep a stable `{ code, message }` shape and intentionally avoid returning raw provider, store, hook, token issuer, or access-token validator details. Use the core `hooks.onError` to observe the original error object for private server-side logs; the separate
|
|
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.
|
|
1147
2059
|
|
|
1148
2060
|
## Security Checklist
|
|
1149
2061
|
|
|
1150
2062
|
Use these defaults when deploying the package:
|
|
1151
2063
|
|
|
1152
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
|
|
1153
2066
|
- never store the upstream refresh token in the browser
|
|
1154
2067
|
- use HTTPS end-to-end for frontend, backend, and IdP communication
|
|
1155
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
|
|
@@ -1162,7 +2075,13 @@ Use these defaults when deploying the package:
|
|
|
1162
2075
|
- when using cookie transport, rely on cookie credentials only for `refresh` and `logout`; do not send fallback body `sessionId` values
|
|
1163
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`
|
|
1164
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
|
|
1165
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
|
|
1166
2085
|
- treat upstream OAuth `expires_in`, local access-token lifetime, and vault-session expiry as separate policies
|
|
1167
2086
|
- use Redis or MongoDB, not the memory store, for production or multi-instance deployments
|
|
1168
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
|