@web-ts-toolkit/express-oidc-vault 0.53.0 → 0.55.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 +416 -45
- package/index.d.mts +200 -8
- package/index.d.ts +200 -8
- package/index.js +992 -186
- package/index.mjs +988 -187
- package/package.json +1 -1
- package/wire.d.mts +11 -1
- package/wire.d.ts +11 -1
package/README.md
CHANGED
|
@@ -147,6 +147,7 @@ The core middleware exposes these endpoints under a configurable base path:
|
|
|
147
147
|
- `POST /auth/oidc/exchange`
|
|
148
148
|
- `POST /auth/oidc/refresh`
|
|
149
149
|
- `POST /auth/oidc/logout`
|
|
150
|
+
- `POST /auth/oidc/logout/navigation` and `GET /auth/oidc/logout/navigation` when `logoutNavigation` is configured (JSON initiation, then one-time navigation)
|
|
150
151
|
- `POST /auth/oidc/backchannel-logout`
|
|
151
152
|
|
|
152
153
|
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.
|
|
@@ -198,7 +199,7 @@ Use the memory store for local development and tests. For production deployments
|
|
|
198
199
|
| `storeProvider` | required | Durable vault store provider. Use Redis or MongoDB for production and multi-instance deployments. |
|
|
199
200
|
| `config` | required provider values | Supply `issuer` and `clientId`, or use `resolveOidcVaultConfigFromEnv(process.env)`. Endpoint settings select manual mode; see Config Helpers. |
|
|
200
201
|
| `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`. |
|
|
201
|
-
| `postLogoutRedirectUri` | unset |
|
|
202
|
+
| `postLogoutRedirectUri` | unset | Provider-registered return URL for redirected logout and opt-in navigation. Navigation applies stricter syntax/2048-byte checks; upstream failures retain local logout. |
|
|
202
203
|
| `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. |
|
|
203
204
|
| `authorizationTransactionTtlMs` | `600000` | TTL for one-time authorization transactions created during login. |
|
|
204
205
|
| `exchangeCodeTtlMs` | `30000` | TTL for one-time local exchange codes returned to the frontend callback route. |
|
|
@@ -206,6 +207,7 @@ Use the memory store for local development and tests. For production deployments
|
|
|
206
207
|
| `sessionTransport` | `body` | `body` returns and accepts JSON `sessionId`; `cookie` stores the session pointer in an `HttpOnly` cookie and rejects body-only refresh/logout IDs. |
|
|
207
208
|
| `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. |
|
|
208
209
|
| `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. |
|
|
210
|
+
| `logoutNavigation` | disabled | Proof-authorized JSON logout and headerless continuation. Required fallback, default `ticketTtlMs: 60000` (1000..120000), default namespace label `default`. Requires DPoP and ticket-store capabilities. |
|
|
209
211
|
| `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. |
|
|
210
212
|
| `transactionCookie` | HTTPS `__Host-oidc_vault_transaction`, Lax | Temporary POST-login browser cookie, independent of session transport; only `name` and `sameSite: 'lax' \| 'none'` are configurable. |
|
|
211
213
|
| `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. |
|
|
@@ -221,10 +223,10 @@ Construction takes an internal resolved snapshot of the options object without m
|
|
|
221
223
|
|
|
222
224
|
## DPoP Quick Start (local JWT + protected API)
|
|
223
225
|
|
|
224
|
-
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.
|
|
226
|
+
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. CORS is an application dependency:
|
|
225
227
|
|
|
226
228
|
```sh
|
|
227
|
-
pnpm add @web-ts-toolkit/express-oidc-vault @web-ts-toolkit/express-oidc-vault-memory-store express
|
|
229
|
+
pnpm add @web-ts-toolkit/express-oidc-vault @web-ts-toolkit/express-oidc-vault-memory-store express cors
|
|
228
230
|
pnpm add -D @types/express @types/node @types/cors typescript
|
|
229
231
|
```
|
|
230
232
|
|
|
@@ -235,10 +237,10 @@ Set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, optional `OIDC_CLIENT_SECRET`, and a stable
|
|
|
235
237
|
```ts
|
|
236
238
|
import express from 'express';
|
|
237
239
|
import cors from 'cors';
|
|
238
|
-
import { SignJWT } from 'jose';
|
|
239
240
|
import {
|
|
240
241
|
createOidcVaultMiddleware,
|
|
241
242
|
createOidcVaultAccessTokenMiddleware,
|
|
243
|
+
createOidcVaultJwtAccessTokenIssuer,
|
|
242
244
|
createOidcVaultJwtAccessTokenValidator,
|
|
243
245
|
} from '@web-ts-toolkit/express-oidc-vault';
|
|
244
246
|
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
|
|
@@ -276,20 +278,17 @@ app.use(
|
|
|
276
278
|
sessionTransport: 'body',
|
|
277
279
|
sessionTtlMs: 8 * 60 * 60 * 1000,
|
|
278
280
|
deviceBinding: { mode: 'required' }, // ES256, age 60s, skew 5s, nonces off.
|
|
279
|
-
tokenIssuer: {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
.setExpirationTime('5m')
|
|
289
|
-
.sign(signingKey);
|
|
290
|
-
return { accessToken, tokenType: 'DPoP', expiresIn: 300 };
|
|
281
|
+
tokenIssuer: createOidcVaultJwtAccessTokenIssuer({
|
|
282
|
+
key: signingKey,
|
|
283
|
+
algorithm: 'HS256',
|
|
284
|
+
issuer: backendOrigin,
|
|
285
|
+
audience,
|
|
286
|
+
lifetimeSeconds: 300,
|
|
287
|
+
mapClaims({ session }) {
|
|
288
|
+
// Application-owned allowlist; account/permission decisions belong here.
|
|
289
|
+
return { email: typeof session.user?.email === 'string' ? session.user.email : undefined };
|
|
291
290
|
},
|
|
292
|
-
},
|
|
291
|
+
}),
|
|
293
292
|
}),
|
|
294
293
|
);
|
|
295
294
|
app.use(
|
|
@@ -310,7 +309,7 @@ app.get('/api/profile', (req, res) => {
|
|
|
310
309
|
app.listen(3000);
|
|
311
310
|
```
|
|
312
311
|
|
|
313
|
-
Local HS256 signing and ES256 proof signing are independent.
|
|
312
|
+
Local HS256 signing and ES256 proof signing are independent. The issuer helper captures **verified `IssueTokenInput.deviceBinding`** for `cnf.jkt` and automatically selects DPoP/Bearer; mutable profile/session fields are not proof authority. This JWT 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.
|
|
314
313
|
|
|
315
314
|
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.
|
|
316
315
|
|
|
@@ -685,6 +684,8 @@ export async function getBoundProfile(signal?: AbortSignal): Promise<unknown> {
|
|
|
685
684
|
return response.json();
|
|
686
685
|
}
|
|
687
686
|
export const signOut = (): Promise<void> => session.logout();
|
|
687
|
+
// With server logoutNavigation enabled:
|
|
688
|
+
export const signOutThroughProvider = (): Promise<void> => session.logoutAndNavigate();
|
|
688
689
|
|
|
689
690
|
// Explicit user action for an existing corrupt key; ordinary sign-in needs no reset.
|
|
690
691
|
export async function recoverBrowserKey(): Promise<void> {
|
|
@@ -711,7 +712,7 @@ token wait. `clear()` immediately invalidates local auth/owned HTTP and preserve
|
|
|
711
712
|
the key; `dispose()` closes the context while preserving committed reload state;
|
|
712
713
|
`resetKey()` atomically deletes only this scope's key/coordination records.
|
|
713
714
|
Await clear/reset before disposing if their persistence must finish. Only
|
|
714
|
-
`logout()`
|
|
715
|
+
`logout()` and `logoutAndNavigate()` request server revocation. Reset cannot remove HttpOnly cookies,
|
|
715
716
|
revoke old sessions/JWTs, or undo already-started API work; other sessions sharing
|
|
716
717
|
the deleted key need fresh login.
|
|
717
718
|
|
|
@@ -1212,6 +1213,8 @@ Only set `cookie.domain` (for example `.example.com`) as an advanced expansion w
|
|
|
1212
1213
|
## Main Exports
|
|
1213
1214
|
|
|
1214
1215
|
- `createOidcVaultAccessTokenMiddleware(...)`
|
|
1216
|
+
- `createOidcVaultAuthenticator(...)`, `type OidcVaultAuthenticator`
|
|
1217
|
+
- `OidcVaultInvalidAccessTokenError`, `OidcVaultAuthDeniedError`
|
|
1215
1218
|
- `createOidcVaultJwtAccessTokenValidator(...)`
|
|
1216
1219
|
- `createOidcVaultMiddleware(...)`
|
|
1217
1220
|
- `DEFAULT_OIDC_VAULT_BASE_PATH`
|
|
@@ -1316,6 +1319,22 @@ Every provider accepts `dpopReplayMaxEntries?: number`, default **100000**, posi
|
|
|
1316
1319
|
|
|
1317
1320
|
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.
|
|
1318
1321
|
|
|
1322
|
+
### Dedicated Logout Continuation Storage
|
|
1323
|
+
|
|
1324
|
+
All three built-in factories also satisfy `OidcVaultLogoutNavigationStoreProvider`. This independent capability adds `createLogoutNavigationTicket(input): Promise<void>` and `consumeLogoutNavigationTicket({ namespace, ticketHash }): Promise<OidcVaultLogoutNavigationTicket | null>`. Both methods remain **optional on `OidcVaultStoreProvider`** for existing custom stores. Enable core `logoutNavigation` separately to register the continuation routes; storage alone does not change ordinary logout.
|
|
1325
|
+
|
|
1326
|
+
The core root exports `OidcVaultLogoutNavigationTicket`, `CreateLogoutNavigationTicketInput`, `ConsumeLogoutNavigationTicketInput`, `OidcVaultLogoutNavigationStoreProvider`, and `OidcVaultLogoutNavigationCapacityError`. Exactly five scalar fields are captured before the first await and returned as detached plain records: **`namespace`, `ticketHash`, `upstreamLogoutUrl`, `createdAt`, `expiresAt`**. Extra input fields/serializers are ignored. No raw ticket, full session/profile, handle, subject, or separate upstream tokens are copied. The already-built upstream URL can contain `id_token_hint`: hashing lookup keys does not encrypt this credential-bearing destination. Exclude records/URLs and arbitrary backend errors from logs.
|
|
1327
|
+
|
|
1328
|
+
Namespace is `logout-navigation:v1:` followed by a canonical 43-character SHA-256 base64url digest; `ticketHash` is also a canonical 43-character digest. The continuation protocol uses `lnt_` plus **32 cryptographically random bytes** encoded as unpadded canonical base64url (47 characters total); hash the **entire prefixed ticket** with SHA-256. Scope hashing uses UTF-8 `JSON.stringify(['oidc-vault-logout-navigation', 1, backendOrigin, normalizedBasePath, exactConfiguredIssuer, configuredClientId, namespaceLabel, normalizedFallbackUri, normalizedPostLogoutUriOrNull])`. Scope labels are static printable ASCII, default `default`, 1..128 bytes. Hash formats cannot establish entropy: callers must never intentionally reuse secrets/hashes. Origin/mount/provider/return-policy changes isolate continuations, not legacy sessions.
|
|
1329
|
+
|
|
1330
|
+
Create is **create-only**: live exact-pair collision throws `OidcVaultStoreConflictError` before capacity rejection without changing the first destination/deadline. Invalid/expired create throws `TypeError` without ticket allocation. Epochs and clock must be safe integers within ECMAScript Date range; original lifetime **1000..120000 ms**, remaining lifetime **>0 and ≤120000 ms** (protocol default 60000 ms). Destinations must be absolute HTTP(S), without userinfo, fragment, raw whitespace/control characters or backslashes, and at most **16384 serialized UTF-8 bytes**. Discovered endpoints may use another host. Core/provider clocks must agree; Redis uses server `TIME`, ignoring its application clock for tickets.
|
|
1331
|
+
|
|
1332
|
+
Consume atomically checks the exact pair and `expiresAt > now`, removes the record and returns it once. Invalid input/unknown/spent/expired/malformed selected records return `null`; selected malformed/expired records are retired. Backend, clock and accounting errors **throw**, never successful grants. No read followed by unconditional delete or process-local lock implements shared consumption. An uncertain create/consume can have committed: never retry an arbitrary backend error or restore a consumed record.
|
|
1333
|
+
|
|
1334
|
+
Every built-in accepts `logoutNavigationMaxEntries?: number`, positive safe integer, default **10000**, independent of DPoP replay. Capacity spans effective ticket namespaces in one memory object, MongoDB collection pair or Redis keyPrefix; it is not tenant availability isolation. No live eviction; exhaustion throws `OidcVaultLogoutNavigationCapacityError`. Successful consume frees its own capacity immediately. Each operation reclaims at most **64 unrelated expiries plus its selected record**. Unknown consumes allocate no per-request state; expired accounting awaiting cleanup can conservatively occupy capacity. Session/lineage/backchannel deletion does not cascade to already-authorized continuations. See provider READMEs for physical retention/configuration details.
|
|
1335
|
+
|
|
1336
|
+
Migration: custom stores need this capability only when opting into a continuation flow; device binding's six methods remain separate. Built-in inferred return types expose both capabilities without casts or optional chaining.
|
|
1337
|
+
|
|
1319
1338
|
## Session Identity And Store Namespaces
|
|
1320
1339
|
|
|
1321
1340
|
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.
|
|
@@ -1359,10 +1378,10 @@ Browser-bound backend proofs (BOV-02-FU1) are implemented for opt-in POST login/
|
|
|
1359
1378
|
- Refresh profile precedence: fresh verified ID claims are the base when a new `id_token` is present, freshly fetched matching UserInfo overlays whichever base applies, and the retained profile is used only when no new `id_token` arrives (fresh UserInfo still overlays the retained base per key). Retained values are never merged over fresh claims and are never treated as fresh UserInfo. Claims absent from the fresh sources are dropped when fresh identity arrives, so removed provider claims disappear; keep application custom attributes in `session.metadata`, not in `user`, because application-added `user` keys are not carried forward across a fresh identity refresh.
|
|
1360
1379
|
- `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.
|
|
1361
1380
|
- `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.
|
|
1362
|
-
- `postLogoutRedirectUri` is optional
|
|
1381
|
+
- `postLogoutRedirectUri` is optional and provider-registered; it may be on another origin. Redirected logout and opt-in logout navigation use it. Navigation additionally applies the stricter syntax and length rules below.
|
|
1363
1382
|
- 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.
|
|
1364
1383
|
- backchannel logout revokes local sessions by upstream `sid` when available, otherwise by `sub`
|
|
1365
|
-
- Every vault
|
|
1384
|
+
- Every vault response carries `Cache-Control: no-store`, including parser errors. Legacy `Pragma`/`Expires` are unnecessary. The opt-in `/logout/navigation` route additionally carries `Referrer-Policy: no-referrer`; other routes retain their existing header policy. These headers do not clear history, disable proxy logging, strip callback `?code=` (use frontend `history.replaceState`), or hide protocol-required URL values from the navigation target. Assert both transports' response headers in integration tests.
|
|
1366
1385
|
|
|
1367
1386
|
## Config Helpers
|
|
1368
1387
|
|
|
@@ -1436,12 +1455,11 @@ In manual mode, the minimum required config is:
|
|
|
1436
1455
|
|
|
1437
1456
|
The middleware can return a local backend access token during `exchange` and `refresh` by providing a `tokenIssuer`.
|
|
1438
1457
|
|
|
1439
|
-
This is the default **unbound** example. For bound JWTs use the [complete DPoP server](#complete-dpop-server).
|
|
1458
|
+
This is the default **unbound** example. For bound JWTs use the [complete DPoP server](#complete-dpop-server). The helper omits vault handles from JWTs in both transports; application claims are explicitly allowlisted.
|
|
1440
1459
|
|
|
1441
1460
|
```ts
|
|
1442
1461
|
import express from 'express';
|
|
1443
|
-
import {
|
|
1444
|
-
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
|
|
1462
|
+
import { createOidcVaultMiddleware, createOidcVaultJwtAccessTokenIssuer } from '@web-ts-toolkit/express-oidc-vault';
|
|
1445
1463
|
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
|
|
1446
1464
|
|
|
1447
1465
|
const app = express();
|
|
@@ -1479,27 +1497,16 @@ app.use(
|
|
|
1479
1497
|
frontendRedirectUri: 'https://frontend.example.com/callback',
|
|
1480
1498
|
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
|
|
1481
1499
|
storeProvider: createMemoryOidcVaultStore(),
|
|
1482
|
-
tokenIssuer: {
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
.setIssuer(localTokenIssuer)
|
|
1491
|
-
.setAudience(localTokenAudience)
|
|
1492
|
-
.setIssuedAt()
|
|
1493
|
-
.setExpirationTime('15m')
|
|
1494
|
-
.sign(jwtSecret);
|
|
1495
|
-
|
|
1496
|
-
return {
|
|
1497
|
-
accessToken,
|
|
1498
|
-
expiresIn: 900,
|
|
1499
|
-
tokenType: 'Bearer',
|
|
1500
|
-
};
|
|
1500
|
+
tokenIssuer: createOidcVaultJwtAccessTokenIssuer({
|
|
1501
|
+
key: jwtSecret,
|
|
1502
|
+
algorithm: 'HS256',
|
|
1503
|
+
issuer: localTokenIssuer,
|
|
1504
|
+
audience: localTokenAudience,
|
|
1505
|
+
lifetimeSeconds: 900,
|
|
1506
|
+
mapClaims({ session }) {
|
|
1507
|
+
return { email: typeof session.user?.email === 'string' ? session.user.email : undefined };
|
|
1501
1508
|
},
|
|
1502
|
-
},
|
|
1509
|
+
}),
|
|
1503
1510
|
}),
|
|
1504
1511
|
);
|
|
1505
1512
|
```
|
|
@@ -1510,6 +1517,45 @@ That local token is separate from the upstream IdP access token:
|
|
|
1510
1517
|
- the frontend receives local token fields and the session's `user` profile; body transport also includes the opaque `sessionId`, while cookie transport omits it
|
|
1511
1518
|
- the app-issued access token can contain only the claims your backend APIs actually need
|
|
1512
1519
|
|
|
1520
|
+
### JWT issuer helper
|
|
1521
|
+
|
|
1522
|
+
`createOidcVaultJwtAccessTokenIssuer(options): OidcVaultTokenIssuer` is the signing counterpart of `createOidcVaultJwtAccessTokenValidator`. Configure the signing key/algorithm, exact nonblank issuer and nonempty audience (string or string array). The defaults are `lifetimeSeconds: 300`, `now: Date.now`, `sub: session.subject` and `scope: session.scope`. No key or algorithm is inferred. The protected header is `{ alg: algorithm, typ: 'JWT' }`.
|
|
1523
|
+
|
|
1524
|
+
```ts
|
|
1525
|
+
import {
|
|
1526
|
+
createOidcVaultJwtAccessTokenIssuer,
|
|
1527
|
+
type OidcVaultJwtAccessTokenIssuerOptions,
|
|
1528
|
+
type OidcVaultTokenIssuer,
|
|
1529
|
+
} from '@web-ts-toolkit/express-oidc-vault';
|
|
1530
|
+
|
|
1531
|
+
declare const signingKey: Uint8Array; // Your configured random secret, never a public fallback.
|
|
1532
|
+
declare function loadPermissions(subject: string): Promise<string[]>;
|
|
1533
|
+
|
|
1534
|
+
const options: OidcVaultJwtAccessTokenIssuerOptions = {
|
|
1535
|
+
key: signingKey,
|
|
1536
|
+
algorithm: 'HS256',
|
|
1537
|
+
issuer: 'https://api.example.com',
|
|
1538
|
+
audience: ['app-api-v1'],
|
|
1539
|
+
lifetimeSeconds: 300,
|
|
1540
|
+
// Optional application subject namespace; runs before mapClaims.
|
|
1541
|
+
mapSubject: ({ session }) => `app:${session.subject}`,
|
|
1542
|
+
async mapClaims({ session, operation }) {
|
|
1543
|
+
const permissions = await loadPermissions(session.subject);
|
|
1544
|
+
return { permissions, scope: 'api:read', issuanceOperation: operation };
|
|
1545
|
+
},
|
|
1546
|
+
};
|
|
1547
|
+
const tokenIssuer: OidcVaultTokenIssuer = createOidcVaultJwtAccessTokenIssuer(options);
|
|
1548
|
+
void tokenIssuer; // Pass as createOidcVaultMiddleware({ ..., tokenIssuer }).
|
|
1549
|
+
```
|
|
1550
|
+
|
|
1551
|
+
Supported `OidcVaultJwtSigningAlgorithm` values: **HS256/384/512**, **RS256/384/512**, **PS256/384/512**, **ES256/384/512**, and **EdDSA (Ed25519)**. Accepts JOSE secret `Uint8Array`, secret/private Node `KeyObject`, signing `CryptoKey`, or secret/private `JWK`. Construction rejects incompatible type/curve/hash/usage, HMAC keys shorter than the hash size (32/48/64 bytes), RSA below 2048 bits, public-only keys, and contradictory JWK `alg`/`use`/`key_ops`. Use a generic RSA private key for RS/PS; restricted `rsa-pss` KeyObjects are unsupported. Import PEM material yourself with JOSE/Node; key rotation and JWKS serving remain application-owned.
|
|
1552
|
+
|
|
1553
|
+
The factory captures options/callback/clock references and copies audience arrays and mutable secret/JWK material; immutable native keys remain referenced. Later caller mutations do not reconfigure it. Lifetime must be nonnegative safe-integer seconds. Clock values must be nonnegative safe-integer epoch milliseconds, and lifetime conversion, response-deadline addition and JWT expiry milliseconds must remain safe. Validation runs at construction and again after mapping immediately before signing. JWT `iat = floor(now() / 1000)`, `exp = iat + lifetimeSeconds`, and returned `expiresIn = lifetimeSeconds`: subsecond rounding can shorten effective validity by less than a second. Zero lifetime signs an already-expired token with `exp === iat`, returns `expiresIn: 0`, and still permits delivery of a rotated vault handle/cookie; APIs reject its expiry and the browser retains no reusable credential.
|
|
1554
|
+
|
|
1555
|
+
Both optional mappers may be async and receive `IssueTokenInput` with `operation: 'exchange' | 'refresh'`. `mapSubject` must return a nonblank subject. `mapClaims` returns `OidcVaultJwtAccessTokenClaims`, an explicit application allowlist. It can override default scope, or omit it with `scope: undefined`; it must not supply **`iss`, `aud`, `iat`, `exp`, `sub`, or `cnf`**, even with undefined. Attempts reject with TypeError, including fields produced by JSON serializers. Claims are JSON-materialized before security-owned fields are attached. Non-object results and serialization failures reject. Subject/scope defaults and binding are captured before callbacks; only the authoritative input `deviceBinding` selects exact `cnf: { jkt }`/`DPoP`, and its absence selects no cnf/`Bearer`. Mutable session/profile/metadata binding never enrolls or rebinds a token.
|
|
1556
|
+
|
|
1557
|
+
No vault `sid`, session metadata, entire profile, or upstream tokens are copied automatically. Deliberate application claims remain trusted; keep business-user lookup, account/archived checks, roles/permissions and provisioning in callbacks. Mapper/serialization/clock/signing failures reach existing exchange/refresh rollback and private `hooks.onError`; committed outcomes still come from `onTokenOutcome`. Custom JWT/opaque unbound issuers and their existing output checks remain supported.
|
|
1558
|
+
|
|
1513
1559
|
### Local issuer result contract
|
|
1514
1560
|
|
|
1515
1561
|
`tokenIssuer.issue` must resolve to a non-null, non-array object with:
|
|
@@ -1530,9 +1576,120 @@ Malformed results return HTTP 500 with `{"code":"OIDC_VAULT_INTERNAL_ERROR","mes
|
|
|
1530
1576
|
|
|
1531
1577
|
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.
|
|
1532
1578
|
|
|
1579
|
+
## Proof-Authorized Logout Navigation
|
|
1580
|
+
|
|
1581
|
+
Enable `logoutNavigation` to revoke locally using the original browser key, then navigate the document through an opaque backend ticket. An HTTP fetch following a provider 302 does not navigate the page, and browser manual redirects do not expose a usable Location. The provider's `id_token_hint` URL is never returned in JSON.
|
|
1582
|
+
|
|
1583
|
+
### Logout navigation server
|
|
1584
|
+
|
|
1585
|
+
```ts
|
|
1586
|
+
import express from 'express';
|
|
1587
|
+
import { createOidcVaultMiddleware, type OidcVaultLogoutNavigationOptions } from '@web-ts-toolkit/express-oidc-vault';
|
|
1588
|
+
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
|
|
1589
|
+
|
|
1590
|
+
const logoutNavigation: OidcVaultLogoutNavigationOptions = {
|
|
1591
|
+
fallbackRedirectUri: 'https://app.example.com/signed-out',
|
|
1592
|
+
ticketTtlMs: 60_000,
|
|
1593
|
+
namespace: 'app-v1',
|
|
1594
|
+
};
|
|
1595
|
+
const app = express();
|
|
1596
|
+
app.use(
|
|
1597
|
+
createOidcVaultMiddleware({
|
|
1598
|
+
backendOrigin: 'https://api.example.com',
|
|
1599
|
+
frontendRedirectUri: 'https://app.example.com/callback',
|
|
1600
|
+
trustedOrigins: ['https://app.example.com'],
|
|
1601
|
+
config: { issuer: 'https://provider.example.com', clientId: 'app' },
|
|
1602
|
+
deviceBinding: {},
|
|
1603
|
+
storeProvider: createMemoryOidcVaultStore(), // Shared Redis/MongoDB for multiple processes.
|
|
1604
|
+
postLogoutRedirectUri: 'https://app.example.com/provider-return',
|
|
1605
|
+
logoutNavigation,
|
|
1606
|
+
}),
|
|
1607
|
+
);
|
|
1608
|
+
```
|
|
1609
|
+
|
|
1610
|
+
Construction requires a plain options object, configured `deviceBinding`, its six store capabilities and both `createLogoutNavigationTicket`/`consumeLogoutNavigationTicket`. Missing capabilities fail synchronously with a feature/method-specific error. Options, normalized return destinations, TTL and derived namespace are detached/frozen; service references stay live. Feature-disabled custom stores need neither new method and register neither route.
|
|
1611
|
+
|
|
1612
|
+
`fallbackRedirectUri` is required, static, at most **2048 serialized UTF-8 bytes**, HTTPS (loopback HTTP development allowed), without userinfo, fragment, raw whitespace/backslashes/controls. It must stay on the pinned backend or configured frontend origin and cannot be the backend continuation path, even with a query. Static query parameters are allowed. `trustedOrigins` grants source authority only. When navigation is enabled, `postLogoutRedirectUri` receives the same syntax/length checks, with no fallback-origin restriction; register it with the provider. Both are operator configuration, never request destinations.
|
|
1613
|
+
|
|
1614
|
+
### Browser navigation logout
|
|
1615
|
+
|
|
1616
|
+
Use the companion client's public method after enabling the server above. It
|
|
1617
|
+
owns bounded HTTP, original-key proof, nonce retry, cleanup and navigation:
|
|
1618
|
+
|
|
1619
|
+
```ts
|
|
1620
|
+
import { createOidcVaultDpopSession } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
1621
|
+
|
|
1622
|
+
const session = createOidcVaultDpopSession({
|
|
1623
|
+
backendOrigin: 'https://api.example.com',
|
|
1624
|
+
basePath: '/auth/oidc',
|
|
1625
|
+
sessionTransport: 'body',
|
|
1626
|
+
});
|
|
1627
|
+
export const signOutThroughProvider = (): Promise<void> => session.logoutAndNavigate();
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
An expired/absent local JWT needs no preliminary refresh: retain the original
|
|
1631
|
+
persistent key and body handle or active matching cookie metadata. The client
|
|
1632
|
+
accepts only the exact pinned backend bare/canonical-ticket URL. Confirmed logout
|
|
1633
|
+
clears old state even on an invalid URL (`INVALID_LOGOUT_NAVIGATION_RESPONSE`);
|
|
1634
|
+
unconfirmed/transport failures never automatically retry or navigate. Concurrent
|
|
1635
|
+
clicks share one Promise through cleanup and one synchronous `navigate` adapter
|
|
1636
|
+
call under lifecycle authority. Later login/exchange/local logout/clear/resetKey/
|
|
1637
|
+
dispose withdraws old navigation intent immediately, even with login queued.
|
|
1638
|
+
Earlier refresh rotation is published first; later refresh cannot restore auth.
|
|
1639
|
+
The client root re-exports both pure navigation DTOs without Node/Express types.
|
|
1640
|
+
Keep `logout()` for local-only behavior, add the new method to typed mocks, and
|
|
1641
|
+
make custom navigation adapters hand off synchronously. See the client's
|
|
1642
|
+
[navigation contract](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md#provider-logout-navigation)
|
|
1643
|
+
and real Chromium/Firefox reference example for both transports.
|
|
1644
|
+
|
|
1645
|
+
### Navigation wire protocol
|
|
1646
|
+
|
|
1647
|
+
Root and `/wire` export pure `OidcVaultLogoutNavigationInput { sessionId? }` and `OidcVaultLogoutNavigationResult { loggedOut: true, navigationUrl }`. This single-attempt example shows the HTTP boundary; a browser integration must also serialize lifecycle operations, retry a recognized nonce challenge once with a fresh proof/JTI, clean up confirmed local state and suppress stale navigation intents.
|
|
1648
|
+
|
|
1649
|
+
```ts
|
|
1650
|
+
import type {
|
|
1651
|
+
OidcVaultLogoutNavigationInput,
|
|
1652
|
+
OidcVaultLogoutNavigationResult,
|
|
1653
|
+
OidcVaultSessionTransport,
|
|
1654
|
+
} from '@web-ts-toolkit/express-oidc-vault/wire';
|
|
1655
|
+
|
|
1656
|
+
async function initiateLogoutNavigation(
|
|
1657
|
+
input: OidcVaultLogoutNavigationInput,
|
|
1658
|
+
freshOriginalKeyProof: string,
|
|
1659
|
+
transport: OidcVaultSessionTransport,
|
|
1660
|
+
): Promise<OidcVaultLogoutNavigationResult> {
|
|
1661
|
+
const response = await fetch('https://api.example.com/auth/oidc/logout/navigation', {
|
|
1662
|
+
method: 'POST',
|
|
1663
|
+
credentials: transport === 'cookie' ? 'include' : 'omit',
|
|
1664
|
+
redirect: 'error',
|
|
1665
|
+
headers: { 'Content-Type': 'application/json', DPoP: freshOriginalKeyProof },
|
|
1666
|
+
body: JSON.stringify(transport === 'cookie' ? {} : input),
|
|
1667
|
+
});
|
|
1668
|
+
if (!response.ok) throw new Error('Logout initiation failed.');
|
|
1669
|
+
return response.json() as Promise<OidcVaultLogoutNavigationResult>;
|
|
1670
|
+
}
|
|
1671
|
+
```
|
|
1672
|
+
|
|
1673
|
+
POST accepts only a JSON object with optional string `sessionId`: body transport needs its nonempty handle; cookie transport selects only the configured cookie and ignores the body handle. Arrays/other fields are `400 OIDC_VAULT_INVALID_REQUEST_BODY`; unsupported media is `415 OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE`. Query destinations are ignored. Strict raw Origin/Referer checks apply in **both transports** before store/proof work: absent/null/foreign/ambiguous sources reject; Referer is used only without Origin. Live/alias targets must have an original stored DPoP binding even under optional mode. An optional-unbound target gets `401 OIDC_VAULT_DEVICE_BINDING_REQUIRED` and can still use ordinary `/logout`. No local access JWT, Authorization, preliminary refresh, fingerprint or `ath` is needed. Proof/nonce/shared replay policies, explicit overrides and `logout` diagnostics are preserved.
|
|
1674
|
+
|
|
1675
|
+
Successful POST returns exactly `200 { loggedOut: true, navigationUrl }`. With an acknowledged ticket it is `https://api.example.com/auth/oidc/logout/navigation?ticket=lnt_<43 canonical base64url characters>`; otherwise it is that **bare absolute backend path**, whose GET selects fallback. Validate the exact pinned origin/path and either no query or one literal canonical ticket before top-level navigation; reject relative URLs, userinfo, fragments, alternate encodings, duplicate/extra parameters or other origins/paths. On confirmed `loggedOut: true`, clear local credentials even if its navigation URL is invalid, then reject without navigating. A transport/malformed-response failure leaves the server commit uncertain; do not automatically repeat the mutation or navigate. Repeated clicks should coalesce through cleanup/navigation, not cache a completed URL.
|
|
1676
|
+
|
|
1677
|
+
The order is original identity/key/proof -> context recheck -> live `onBeforeLogout` -> **await lineage deletion** -> configured cookie clear -> existing provider discovery/build -> ticket persistence -> live `onLogout` -> JSON. Ticket time is sampled after discovery: default **60000 ms**, integer **1000..120000 ms** inclusive, with Date-range epoch arithmetic checked at construction and issuance. Definite create conflicts get at most three independent random attempts sharing one deadline; other storage failures are not retried. Only the final provider URL, bounded to **16384 serialized UTF-8 bytes** and HTTP(S) without userinfo/fragment/raw unsafe characters, survives in the five-field server ticket. The ticket is 32 random bytes with `lnt_` prefix; storage sees only SHA-256 of the entire secret and an exact origin/mount/issuer/client/label/return-policy scope. See dedicated storage below for capacity, cleanup and provider retention.
|
|
1678
|
+
|
|
1679
|
+
Aliases authorize revocation only: no successor credential lookup, discovery, ticket or logout hooks. Missing/expired targets are idempotent bare-path success with no reservation/deletion/hooks. Missing original hint/endpoint also yields the bare path. Provider/build/ticket errors after deletion retain confirmed local logout, report privately through `onError` and still notify the live post-hook once. A deletion rejection, including partial deletion/alias-cleanup failure, is **uncertain**: sanitized error, no success DTO/navigation URL, cookie-clear signal or post-hook. Cookie preparation failure after deletion retains revocation, emits the live post-hook, errors and mints no ticket. Hooks retain route `logout` and owned original snapshots; no new credential metadata or token-outcome event is emitted. Hook notification is per admitted live request, not globally exactly-once; concurrent live initiations can each issue a grant. Crashes or response loss can lose notifications or leave an undelivered grant until expiry; credentials are never recreated.
|
|
1680
|
+
|
|
1681
|
+
GET needs no cookie, handle, API token or proof. It accepts only one canonical raw `ticket` parameter, hashes it in its pinned namespace, atomically consumes it **before** writing an empty-body 302 Location and validates returned identity/window/URL. It does no discovery/session work/hooks/diagnostics and cannot clear a newer login cookie. Missing/malformed/duplicate/extra/unknown/expired/spent/foreign tickets select the fixed fallback; malformed queries never call storage. Consume errors/invalid adapter records report privately and also select fallback, without restore or ambiguous retry. Cross-instance simultaneous GETs have one upstream winner. Response loss after consume burns the grant. Explicit HEAD is **405, Allow: GET, POST**, before GET/body parsing, and never consumes; other methods cannot spend tickets, and CORS OPTIONS remains caller-owned.
|
|
1682
|
+
|
|
1683
|
+
All new-route success/error/redirect responses carry **Cache-Control: no-store** and **Referrer-Policy: no-referrer**. JSON contains no upstream URL/token, session handle, subject or provider fields. The short-lived ticket is a bearer continuation, independent of cleared cookies/IP/user agent; possession can spend only that continuation. URLs/history/proxy logging and the IdP-visible hint remain navigation exposures, and lookup hashing does not encrypt the stored destination. After upstream handoff, IdP availability/termination/return is not guaranteed. Local logout does not revoke stateless JWTs; existing local/302 POST logout, signed backchannel logout and override contracts remain supported.
|
|
1684
|
+
|
|
1533
1685
|
## Migration And Behavior Changes
|
|
1534
1686
|
|
|
1535
|
-
-
|
|
1687
|
+
- Logout navigation is additive and opt-in. Use JSON POST plus top-level navigation through the backend continuation when integrating proof-authorized upstream logout. Replace duplicated discovery/cookie/alias logic with this protocol. Keep ordinary `/logout` for local-only/legacy unbound sessions. Built-in stores support tickets; custom stores need both optional methods only when enabling `logoutNavigation`. Enabled flows tighten configured return-URI validation as described above. There is no store schema migration for disabled custom stores.
|
|
1688
|
+
|
|
1689
|
+
- The local JWT issuer helper is additive. Replace manual signing/`cnf.jkt`/tokenType/expiry construction with `createOidcVaultJwtAccessTokenIssuer`, preserving business claims in its mapper. Set an explicit strong key, algorithm, issuer and audience; use `mapSubject` for subject remapping and remove reserved fields from `mapClaims`. It omits vault sid by default, including in cookie transport. Existing custom issuers retain output validation and rollback behavior.
|
|
1690
|
+
- Structured telemetry is additive: opt into `onDpopDiagnostic` and `onTokenOutcome` on vault options, and `onDpopDiagnostic` on API options. `IssueTokenInput` now includes required `operation: 'exchange' | 'refresh'`; update hand-built typed issuer inputs. Existing issuer callbacks may ignore the new field. Move successful-exchange/issuance logging out of signing callbacks into committed outcomes; signing alone is not success. Existing `onError`, enforcement/overrides, rollback, and browser errors/challenges are preserved.
|
|
1691
|
+
- Optional API authentication is additive: `authentication` defaults to `required`, and `invalidCredentials` defaults to `reject`. Binding mode is independent. Use one `createOidcVaultAuthenticator` instance for public enrichment and protected guards; replace response interception and repeated authentication mounts with its handlers. Empty/duplicate/comma-joined/non-ASCII Authorization credentials are rejected, including on the legacy path. Custom validators opting into anonymous fallback must deliberately throw `OidcVaultInvalidAccessTokenError` for credential rejection; arbitrary exceptions keep their existing strict rejection response.
|
|
1692
|
+
- Declaration upgrade: browser DTO consumers can use type-only imports from `@web-ts-toolkit/express-oidc-vault/wire`, including the new navigation input/result. Existing DTO root imports and backend `req.auth` augmentation remain supported in strict ESM/CJS consumers. The companion client ships DOM-only declarations and requires Node **>=22.12.0** for CJS tooling.
|
|
1536
1693
|
- Browser-client upgrade: use the [published client quickstart](#browser-dpop-client) in place of the old standalone implementation. Its shipped [upgrade notes](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md#upgrade-notes) cover finite HTTP budgets/cancellation, FIFO/cleanup, terminal versus recoverable errors, single-attempt nonce learning, gateway path normalization and explicit scoped key recovery. Existing browser keys keep their database name/schema.
|
|
1537
1694
|
- 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.
|
|
1538
1695
|
- 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.
|
|
@@ -1637,6 +1794,71 @@ Returned auth context shape:
|
|
|
1637
1794
|
|
|
1638
1795
|
The package augments Express request typing so `req.auth` is available without casting in TypeScript route handlers.
|
|
1639
1796
|
|
|
1797
|
+
### Optional authentication and request-scoped guards
|
|
1798
|
+
|
|
1799
|
+
`authentication: 'optional'` allows **absent credentials** on public endpoints. It is separate from `deviceBinding.mode: 'optional'`, which permits verified unbound tokens while authentication still defaults to required. `invalidCredentials: 'anonymous'` additionally allows known credential rejections to continue anonymously; it must be explicitly combined with optional authentication. Both options work with `createOidcVaultAccessTokenMiddleware` and `createOidcVaultAuthenticator`.
|
|
1800
|
+
|
|
1801
|
+
```ts
|
|
1802
|
+
import express from 'express';
|
|
1803
|
+
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';
|
|
1804
|
+
import {
|
|
1805
|
+
createOidcVaultAuthenticator,
|
|
1806
|
+
createOidcVaultJwtAccessTokenValidator,
|
|
1807
|
+
OidcVaultAuthDeniedError,
|
|
1808
|
+
} from '@web-ts-toolkit/express-oidc-vault';
|
|
1809
|
+
|
|
1810
|
+
// The application owns user lookup, account state and permissions.
|
|
1811
|
+
declare function findApplicationUser(subject: string): Promise<{ id: string; active: boolean } | null>;
|
|
1812
|
+
const rawSecret = process.env.APP_JWT_SECRET;
|
|
1813
|
+
if (!rawSecret || Buffer.byteLength(rawSecret, 'utf8') < 32) {
|
|
1814
|
+
throw new Error('APP_JWT_SECRET must encode at least 32 random bytes.');
|
|
1815
|
+
}
|
|
1816
|
+
const auth = createOidcVaultAuthenticator({
|
|
1817
|
+
authentication: 'optional',
|
|
1818
|
+
invalidCredentials: 'reject', // Default. Choose 'anonymous' explicitly if this public API permits it.
|
|
1819
|
+
validator: createOidcVaultJwtAccessTokenValidator({
|
|
1820
|
+
key: new TextEncoder().encode(rawSecret),
|
|
1821
|
+
issuer: 'https://api.example.com',
|
|
1822
|
+
audience: 'app-api',
|
|
1823
|
+
algorithms: ['HS256'],
|
|
1824
|
+
}),
|
|
1825
|
+
deviceBinding: {
|
|
1826
|
+
mode: 'optional',
|
|
1827
|
+
publicOrigin: 'https://api.example.com',
|
|
1828
|
+
replayNamespace: 'app-api',
|
|
1829
|
+
replayStore: createMemoryOidcVaultStore(), // One process; use a shared Redis/MongoDB store across processes.
|
|
1830
|
+
},
|
|
1831
|
+
async onAuthContext({ auth, res }) {
|
|
1832
|
+
const user = await findApplicationUser(auth.subject);
|
|
1833
|
+
if (!user?.active) throw new OidcVaultAuthDeniedError('Account access denied.');
|
|
1834
|
+
res.locals.user = { id: user.id };
|
|
1835
|
+
},
|
|
1836
|
+
});
|
|
1837
|
+
const app = express();
|
|
1838
|
+
app.use('/api', auth.authenticate); // Mount once, before all routes that consume this authenticator.
|
|
1839
|
+
app.get('/api/session', (req, res) => {
|
|
1840
|
+
res.json({ authenticated: !!req.auth, user: res.locals.user ?? null });
|
|
1841
|
+
});
|
|
1842
|
+
app.get('/api/private', auth.requireAuth, (_req, res) => res.json({ user: res.locals.user }));
|
|
1843
|
+
app.get('/api/bound', auth.requireAuth, auth.requireDeviceBinding, (_req, res) => res.json({ user: res.locals.user }));
|
|
1844
|
+
```
|
|
1845
|
+
|
|
1846
|
+
Guards must come **after `authenticate` from the same factory instance**. They do not run validation, proof reservation or enrichment again, even when several compatible guards are chained. `requireAuth` needs completed authentication; `requireDeviceBinding` also needs the token's original confirmation plus accepted original-key proof. An unbound Bearer token with a valid optional proof remains unbound. Permission checks stay application-owned after the authentication guard.
|
|
1847
|
+
|
|
1848
|
+
Evidence is private and scoped to the original Request/Response pair and captured policy. Plain/forged/copied `req.auth`, another request, and another authenticator (even with identical options) cannot authorize a guard. Do not replace or mutate top-level `req.auth` fields between these handlers; guards reject changed context. Enrich in `onAuthContext` or application-owned `res.locals`. Mounting authentication twice, or entering a guard before authentication has finished, invalidates pending work and returns `500 OIDC_VAULT_AUTHENTICATION_REENTRY`; the original operation cannot later attach context or continue downstream. Missing/incompatible/changed evidence returns `401 OIDC_VAULT_AUTHENTICATION_REQUIRED`; an authenticated unbound request fails the bound guard with `401 OIDC_VAULT_DEVICE_BINDING_REQUIRED`.
|
|
1849
|
+
|
|
1850
|
+
| Outcome | Optional/default handling | With explicit `invalidCredentials: 'anonymous'` |
|
|
1851
|
+
| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
|
1852
|
+
| Missing: neither Authorization nor DPoP header | Continue without `req.auth`; required guards reject | Same |
|
|
1853
|
+
| Invalid: empty/malformed/duplicate headers, rejected token, missing/wrong/replayed proof or bound-as-Bearer | Reject with the existing credential/proof response | Continue without `req.auth`; required guards reject |
|
|
1854
|
+
| Authenticated: token, applicable proof checks and enrichment succeed | Attach context and continue | Same |
|
|
1855
|
+
| Challenged: otherwise-valid proof needs a nonce | Preserve status, body, `WWW-Authenticate` and `DPoP-Nonce`; retry once with a fresh proof | Same; never anonymous |
|
|
1856
|
+
| Failed: replay service outage/capacity, adapter contract/unknown exception, hook denial/failure | Terminate with the existing failure contract | Same; never anonymous |
|
|
1857
|
+
|
|
1858
|
+
Known validator rejection means `OidcVaultInvalidAccessTokenError` from a custom validator, or the built-in JWT helper's malformed JWT/JWS, expiry, claims/issuer/audience, signature, disallowed-algorithm, verified confirmation/expiry-profile or default-subject rejection. A custom adapter can `throw new OidcVaultInvalidAccessTokenError('Private diagnostic.')` after determining that the credential is invalid; its client message stays fixed. Malformed adapter results, unsupported/configuration errors and exceptions from custom `mapClaims` do **not** authorize fallback. Neither an arbitrary 401/status/code nor an unknown adapter exception proves credential rejection; these retain their existing sanitized strict responses (including legacy validator 401s).
|
|
1859
|
+
|
|
1860
|
+
Intentional enrichment vetoes use the root-exported `OidcVaultAuthDeniedError(message?, status?: 401 | 403)`: default `403`, fixed code `OIDC_VAULT_AUTH_DENIED`, and an explicitly public message (default `Authentication denied.`). A 401 denial uses the attempted scheme's challenge; a 403 has none. Any unexpected hook exception remains `500 OIDC_VAULT_AUTH_CONTEXT_FAILED`. Denials/failures never become anonymous and do not release an admitted proof. `onError` still observes original rejection errors, including explicit anonymous fallback and guard failures; observer exceptions cannot change outcomes. Anonymous continuation clears library auth and challenge headers after observers and retains `Cache-Control: no-store`.
|
|
1861
|
+
|
|
1640
1862
|
### JWT validator helper
|
|
1641
1863
|
|
|
1642
1864
|
If your local access token is a JWT, you can avoid rewriting the same `jwtVerify(...)` adapter each time.
|
|
@@ -1811,6 +2033,155 @@ Recommended separation:
|
|
|
1811
2033
|
- keep normal API Bearer/DPoP validation in `createOidcVaultAccessTokenMiddleware(...)`
|
|
1812
2034
|
- keep authorization decisions outside the validator middleware
|
|
1813
2035
|
|
|
2036
|
+
## Structured DPoP Diagnostics And Token Outcomes
|
|
2037
|
+
|
|
2038
|
+
Use root-exported `OidcVaultDpopDiagnosticObserver` and
|
|
2039
|
+
`OidcVaultTokenOutcomeObserver` for server telemetry. Configure each vault/API
|
|
2040
|
+
instance explicitly; diagnostics do not use the browser wire entry.
|
|
2041
|
+
|
|
2042
|
+
```ts
|
|
2043
|
+
import type {
|
|
2044
|
+
OidcVaultAccessTokenMiddlewareOptions,
|
|
2045
|
+
OidcVaultDpopDiagnosticObserver,
|
|
2046
|
+
OidcVaultOptions,
|
|
2047
|
+
OidcVaultTokenOutcomeObserver,
|
|
2048
|
+
} from '@web-ts-toolkit/express-oidc-vault';
|
|
2049
|
+
|
|
2050
|
+
const onDpopDiagnostic: OidcVaultDpopDiagnosticObserver = (event) => {
|
|
2051
|
+
if (event.stage === 'freshness' && event.outcome !== 'accepted') {
|
|
2052
|
+
console.info('dpop-time-check', {
|
|
2053
|
+
operation: event.operation,
|
|
2054
|
+
outcome: event.outcome,
|
|
2055
|
+
reason: event.reason,
|
|
2056
|
+
timing: event.timing,
|
|
2057
|
+
correlation: event.correlation,
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
if (event.stage === 'replay' || event.outcome === 'challenged') {
|
|
2061
|
+
console.info('dpop-admission', event);
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
const onTokenOutcome: OidcVaultTokenOutcomeObserver = (event) => {
|
|
2065
|
+
// Persistence, retention and deduplication remain application-owned.
|
|
2066
|
+
console.info('vault-committed-outcome', event);
|
|
2067
|
+
};
|
|
2068
|
+
|
|
2069
|
+
export function instrumentVault(vault: OidcVaultOptions, api: OidcVaultAccessTokenMiddlewareOptions) {
|
|
2070
|
+
return {
|
|
2071
|
+
vault: { ...vault, onDpopDiagnostic, onTokenOutcome },
|
|
2072
|
+
api: { ...api, onDpopDiagnostic },
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
```
|
|
2076
|
+
|
|
2077
|
+
### Check events and verification provenance
|
|
2078
|
+
|
|
2079
|
+
`OidcVaultDpopDiagnosticEvent` has explicit `operation` (`login`, `exchange`,
|
|
2080
|
+
`refresh`, `logout`, `api`), `stage`, `outcome` (`accepted`, `rejected`,
|
|
2081
|
+
`challenged`, `ignored`) and stable literal `reason`. No message matching or JWT
|
|
2082
|
+
decoding is needed. Typical classifications:
|
|
2083
|
+
|
|
2084
|
+
| Stage | Reasons/outcomes |
|
|
2085
|
+
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2086
|
+
| `presentation`, `preflight` | `malformed-proof`, `proof-required`, `binding-required`, `credential-rejected`, `session-unavailable`, `session-mismatch`, `origin-rejected`, `browser-binding-rejected`, `recognition-rejected`, generic `preflight-failed`; ignored `policy-disabled`/`proof-absent`/`target-absent` (no live logout target) |
|
|
2087
|
+
| `header`, `key`, `signature`, `claims` | `unsupported-header`/`malformed-proof`, `invalid-key`, `invalid-signature`, `invalid-claims` |
|
|
2088
|
+
| `target`, `token-hash`, `binding` | `target-mismatch`, `token-hash-mismatch`, `key-mismatch` |
|
|
2089
|
+
| `freshness` | `proof-too-old`, `proof-in-future`, `invalid-time`, `clock-unavailable` |
|
|
2090
|
+
| `nonce` | challenged `nonce-required`/`nonce-invalid`, rejected `invalid-nonce`/`nonce-unavailable` (challenge issuance failed), ignored `nonce-disabled` |
|
|
2091
|
+
| `replay` | `replay-detected` (duplicate/not-live admission), `replay-unavailable` (including capacity, invalid adapter result, and uncertain store failure) |
|
|
2092
|
+
| `proof` | accepted `proof-accepted` after admission |
|
|
2093
|
+
|
|
2094
|
+
Successful individual checks use `check-passed`. Events are produced in check
|
|
2095
|
+
order and stop at rejection/challenge/omitted proof or final proof admission.
|
|
2096
|
+
There are at most 16 events per proof attempt. Headerless callback and guards
|
|
2097
|
+
reusing OVAPP-01 private request evidence perform no proof checks and emit none.
|
|
2098
|
+
A proof accepted before a later hook/provider/issuer failure remains an accepted
|
|
2099
|
+
proof, not a completed login/exchange/API authorization.
|
|
2100
|
+
|
|
2101
|
+
Each frozen `verification` snapshot independently reports signature,
|
|
2102
|
+
target, access-token hash (`tokenHash`), original credential binding,
|
|
2103
|
+
freshness, nonce, and replay. `unchecked` means not reached; `failed` means
|
|
2104
|
+
checked and failed; passed/matched/reserved, disabled/not-applicable,
|
|
2105
|
+
challenged, and explicitly ignored checks are distinct. An override still runs
|
|
2106
|
+
its check and preserves the existing warning/enforcement behavior. Final
|
|
2107
|
+
`proof-accepted` can contain ignored checks; it does not assert strict success.
|
|
2108
|
+
|
|
2109
|
+
`timing` appears only after signature verification and valid signed iat/JTI
|
|
2110
|
+
shape. `provenance: 'signature-verified'` describes the **claim**, not identity or
|
|
2111
|
+
physical-clock accuracy. Freshness enforcement samples the actual check clock,
|
|
2112
|
+
with `checkedAtMs`, `iatSeconds`, and safe `observedAgeMs = checkedAtMs -
|
|
2113
|
+
iatSeconds * 1000` (positive means older, negative means future). Clock source
|
|
2114
|
+
is `configured` or `system`; clock failure reports no fabricated check time.
|
|
2115
|
+
An enabled freshness override separately labels the `fallback` system clock.
|
|
2116
|
+
Unsafe millisecond arithmetic is omitted rather than clamped. Persisting or
|
|
2117
|
+
deduplicating observed differences belongs to the application.
|
|
2118
|
+
|
|
2119
|
+
Correlation reuses already-captured records/validator results and adds **no store
|
|
2120
|
+
reads or replay reservations**. It contains only SHA-256 base64url `subjectRef`
|
|
2121
|
+
and `sessionRef`, computed from UTF-8 `subject + NUL + subjectIdentifier` and
|
|
2122
|
+
`session + NUL + logicalSessionIdentifier` respectively (API uses its validated
|
|
2123
|
+
session claim; record-only exchange preflight uses the code's session handle
|
|
2124
|
+
until a logical-session snapshot is available). Empty/non-string/over-1024-UTF-16-code-unit identifiers are
|
|
2125
|
+
omitted. These are pseudonymous correlation references, not anonymity or
|
|
2126
|
+
authorization credentials. Vault failures are labeled `candidate-session`,
|
|
2127
|
+
never proof that the caller owns the session. API intermediate/failure events
|
|
2128
|
+
are `verified-token`, which alone does not prove bound-token possession. Final
|
|
2129
|
+
original-bound proof admission uses `authenticated` only when every applicable
|
|
2130
|
+
check passes without overrides; this is before application authorization. An
|
|
2131
|
+
optional unbound proof or any ignored check does not promote either association.
|
|
2132
|
+
Login has no session association; logout aliases
|
|
2133
|
+
can carry only a session reference without a subject.
|
|
2134
|
+
|
|
2135
|
+
Application telemetry can join these events with its own request context. For
|
|
2136
|
+
example, establish a generated request ID in Node `AsyncLocalStorage` before
|
|
2137
|
+
mounting the vault/authenticator, and capture that ID synchronously in each
|
|
2138
|
+
observer. Record an allowlisted application user ID separately from the issuer's
|
|
2139
|
+
subject mapper or successful `onAuthContext` user lookup. A request ID joins the
|
|
2140
|
+
records without decoding a proof or performing diagnostic-only session reads.
|
|
2141
|
+
Early proof events precede application lookup/approval; a failed request's
|
|
2142
|
+
candidate association must never be labeled as the authenticated application user.
|
|
2143
|
+
The hashed references alone are not a database-user lookup API: an application's
|
|
2144
|
+
mapped JWT subject can differ from its IdP subject, and session references may be
|
|
2145
|
+
absent or refer to different handle/lineage claims. Account linking, retention and
|
|
2146
|
+
bounded persistence remain application-owned. Copy only an explicit log allowlist,
|
|
2147
|
+
never the Request, auth context, session, profile or entire async-local container.
|
|
2148
|
+
The [composed application fixture](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/test/application-integration.test.ts)
|
|
2149
|
+
exercises this join together with the issuer, user enrichment, optional session
|
|
2150
|
+
route, compatible required guards and navigation logout in both transports. Real
|
|
2151
|
+
top-level browser navigation is covered by the reference app's Chromium/Firefox lane.
|
|
2152
|
+
|
|
2153
|
+
### Committed outcomes and observer delivery
|
|
2154
|
+
|
|
2155
|
+
`IssueTokenInput.operation` is `exchange` or `refresh`, independent of mutable
|
|
2156
|
+
request URLs. `OidcVaultTokenOutcomeEvent` generates `token-issued` once for a
|
|
2157
|
+
validated local issuer result, then `exchange-succeeded` once for exchange.
|
|
2158
|
+
Issuer-less exchange generates only `exchange-succeeded` with `tokenIssued:
|
|
2159
|
+
false`; issuer-less refresh generates neither. The commit point is after code
|
|
2160
|
+
consumption/checked rotation, issuer-output validation, response DTO
|
|
2161
|
+
materialization, existing refresh post-commit notification, and cookie
|
|
2162
|
+
preparation, immediately before HTTP writing. It is not a network-delivery
|
|
2163
|
+
receipt. Issuer/output/rotation validation or rollback failure generates no
|
|
2164
|
+
success. Correlation is `authenticated` for unbound capability use or fully
|
|
2165
|
+
checked original-bound proof; bound override acceptance stays `candidate-session`.
|
|
2166
|
+
Fields are only kind, operation,
|
|
2167
|
+
correlation, deviceBound, and validated expiresIn/tokenType or tokenIssued.
|
|
2168
|
+
|
|
2169
|
+
All new events and nested objects are frozen allowlisted snapshots: **no raw
|
|
2170
|
+
tokens/proofs, keys/thumbprints/JTIs/nonces, credential-bearing sessions, raw
|
|
2171
|
+
identifiers, requests/responses, URLs/query strings, profiles/metadata or
|
|
2172
|
+
arbitrary provider/store/Error text**. Existing private `onError` remains a
|
|
2173
|
+
separate, richer diagnostic contract with its existing ordering and behavior.
|
|
2174
|
+
|
|
2175
|
+
Callbacks are captured at construction and invoked synchronously in production
|
|
2176
|
+
order; returned promises are **not awaited** and their settlement order is
|
|
2177
|
+
unspecified. Delivery is best-effort: each dispatcher (per API instance or vault
|
|
2178
|
+
route/observer) permits at most 32 pending promises and drops excess or synchronous
|
|
2179
|
+
reentrant calls, without queue, retry or timer. A stalled observer cannot stall
|
|
2180
|
+
authentication. Throws/rejections are swallowed, never recursively sent to
|
|
2181
|
+
`onError`, and never undo state or produce unhandled rejections. Use lifecycle
|
|
2182
|
+
veto hooks for application policy, not these observers; durable audit delivery
|
|
2183
|
+
requires application-owned bounded persistence.
|
|
2184
|
+
|
|
1814
2185
|
## Hook Examples
|
|
1815
2186
|
|
|
1816
2187
|
Hooks let the app observe or extend the core OIDC flow without forking the middleware.
|