@web-ts-toolkit/oidc-vault-dpop-client 0.50.0 → 0.52.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 +483 -73
- package/index.d.mts +182 -81
- package/index.d.ts +182 -81
- package/index.js +943 -226
- package/index.mjs +946 -229
- package/llms.txt +52 -37
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@web-ts-toolkit/oidc-vault-dpop-client`
|
|
2
2
|
|
|
3
|
-
Browser-only DPoP client for [`@web-ts-toolkit/express-oidc-vault`](
|
|
3
|
+
Browser-only DPoP client for [`@web-ts-toolkit/express-oidc-vault`](https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault)
|
|
4
4
|
vault flows, with `body` and `cookie` session transports. Framework-agnostic
|
|
5
5
|
vanilla TypeScript: persistent non-extractable P-256 keys, per-attempt proofs,
|
|
6
6
|
bounded nonce/refresh retries, and tab coordination for cookie sessions.
|
|
@@ -12,6 +12,11 @@ and — cookie transport only — `navigator.locks` plus `BroadcastChannel`) laz
|
|
|
12
12
|
behind runtime feature asserts, never at module import time. There is no
|
|
13
13
|
`express`, no `node:*`, and no default export.
|
|
14
14
|
|
|
15
|
+
**Guide:** [Quickstart](#quickstart-body-transport) ·
|
|
16
|
+
[Lifecycle/cleanup](#lifecycle-concurrency-and-cleanup) ·
|
|
17
|
+
[HTTP bounds](#http-bounds-and-cancellation) · [Recovery](#key-loss-and-errors) ·
|
|
18
|
+
[Gateway scopes](#multiple-services-behind-a-gateway) · [Upgrade notes](#upgrade-notes)
|
|
19
|
+
|
|
15
20
|
## Installation
|
|
16
21
|
|
|
17
22
|
```sh
|
|
@@ -22,6 +27,23 @@ pnpm add @web-ts-toolkit/oidc-vault-dpop-client
|
|
|
22
27
|
single install above is enough. Do not install them separately unless your own
|
|
23
28
|
code imports them directly.
|
|
24
29
|
|
|
30
|
+
**Requirements:** secure-context Web Crypto, IndexedDB `CryptoKey` persistence,
|
|
31
|
+
and sessionStorage. Cookie transport also needs Web Locks + BroadcastChannel.
|
|
32
|
+
Feature checks run when creating/using a session, so importing the module is
|
|
33
|
+
safe before browser startup. Keep one lazy session per tab/vault mount.
|
|
34
|
+
|
|
35
|
+
Node tooling/runtime imports require **Node >=22.12.0**: the CJS entry
|
|
36
|
+
synchronously requires ESM-only `jose`, using Node's default `require(esm)`
|
|
37
|
+
support. Node 22.0–22.11 cannot load it without an experimental flag. Node
|
|
38
|
+
importability does not supply the browser storage needed to create a session.
|
|
39
|
+
|
|
40
|
+
Strict DOM-only TypeScript consumers can use `types: []` and
|
|
41
|
+
`skipLibCheck: false` with Bundler or NodeNext resolution. ESM resolves
|
|
42
|
+
`index.d.mts`, CJS resolves `index.d.ts`; both are self-contained, without Node
|
|
43
|
+
types, Express augmentation, or a backend package dependency. In workspace/npm
|
|
44
|
+
source-tree packs these files live under `dist/`; release packaging flattens
|
|
45
|
+
them and rewrites the export map. Always import by package name.
|
|
46
|
+
|
|
25
47
|
## Canonical imports
|
|
26
48
|
|
|
27
49
|
Named root imports only; no default export, no deep `dist/*` or `src/*` imports.
|
|
@@ -62,7 +84,11 @@ mount (the core default is `/auth/oidc`; the example app mounts
|
|
|
62
84
|
`/auth/oidc/body` and `/auth/oidc/cookie` side by side — pick the one you call).
|
|
63
85
|
|
|
64
86
|
```ts
|
|
65
|
-
import {
|
|
87
|
+
import {
|
|
88
|
+
createOidcVaultDpopSession,
|
|
89
|
+
fetchWithDpop,
|
|
90
|
+
OidcVaultDpopClientError,
|
|
91
|
+
} from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
66
92
|
|
|
67
93
|
const backendOrigin = 'https://api.example.com';
|
|
68
94
|
const session = createOidcVaultDpopSession({
|
|
@@ -77,15 +103,21 @@ export async function signIn(): Promise<void> {
|
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
// On /callback or application bootstrap:
|
|
80
|
-
export async function bootstrap(): Promise<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
export async function bootstrap(): Promise<'ready' | 'sign-in'> {
|
|
107
|
+
try {
|
|
108
|
+
const url = new URL(location.href);
|
|
109
|
+
const code = url.searchParams.get('code');
|
|
110
|
+
if (code !== null) {
|
|
111
|
+
url.searchParams.delete('code');
|
|
112
|
+
history.replaceState(null, '', url.href);
|
|
113
|
+
await session.exchange(code);
|
|
114
|
+
} else {
|
|
115
|
+
await session.refresh(); // Existing persistent key only; no Authorization/ath.
|
|
116
|
+
}
|
|
117
|
+
return 'ready';
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error instanceof OidcVaultDpopClientError && error.requiresLogin) return 'sign-in';
|
|
120
|
+
throw error; // Keep recovery state on transient/proof/nonce failures.
|
|
89
121
|
}
|
|
90
122
|
}
|
|
91
123
|
|
|
@@ -109,6 +141,13 @@ JWT expiry never blocks `refresh`. All vault and API credential responses are
|
|
|
109
141
|
`Cache-Control: no-store`; body handles are tab-local (see
|
|
110
142
|
[Storage](#storage)).
|
|
111
143
|
|
|
144
|
+
On a new tab with no usable login, bootstrap returns `'sign-in'`; show the
|
|
145
|
+
sign-in action. On success it has accepted the response contract, including
|
|
146
|
+
valid zero-lifetime credentials ([bound profile](#bound-credential-response)).
|
|
147
|
+
Render a recoverable error for other failures rather than clearing auth or
|
|
148
|
+
automatically repeating mutations. A corrupt persisted key needs the explicit
|
|
149
|
+
[reset-then-login action](#explicit-recovery-from-a-corrupt-key).
|
|
150
|
+
|
|
112
151
|
## Quickstart: cookie transport
|
|
113
152
|
|
|
114
153
|
Same session shape, `sessionTransport: 'cookie'`, and the matching cookie
|
|
@@ -117,11 +156,10 @@ mount. Cookie mode additionally requires **Web Locks** and
|
|
|
117
156
|
`DPOP_TAB_COORDINATION_UNSUPPORTED`.
|
|
118
157
|
|
|
119
158
|
```ts
|
|
120
|
-
import { createOidcVaultDpopSession
|
|
159
|
+
import { createOidcVaultDpopSession } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
121
160
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
backendOrigin,
|
|
161
|
+
export const session = createOidcVaultDpopSession({
|
|
162
|
+
backendOrigin: 'https://api.example.com',
|
|
125
163
|
basePath: '/auth/oidc/cookie',
|
|
126
164
|
sessionTransport: 'cookie',
|
|
127
165
|
});
|
|
@@ -129,45 +167,9 @@ const session = createOidcVaultDpopSession({
|
|
|
129
167
|
export async function signIn(): Promise<void> {
|
|
130
168
|
await session.login('/callback?transport=cookie');
|
|
131
169
|
}
|
|
132
|
-
|
|
133
|
-
export async function bootstrap(): Promise<void> {
|
|
134
|
-
const url = new URL(location.href);
|
|
135
|
-
const code = url.searchParams.get('code');
|
|
136
|
-
if (code !== null) {
|
|
137
|
-
url.searchParams.delete('code');
|
|
138
|
-
history.replaceState(null, '', url.href);
|
|
139
|
-
await session.exchange(code);
|
|
140
|
-
} else {
|
|
141
|
-
await session.refresh(); // Single-flight; winner token shared over BroadcastChannel.
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export async function getProfile(): Promise<unknown> {
|
|
146
|
-
const response = await fetchWithDpop(
|
|
147
|
-
{ session, apis: [{ origin: backendOrigin, replayNamespace: 'my-app-api' }] },
|
|
148
|
-
`${backendOrigin}/api/profile`,
|
|
149
|
-
);
|
|
150
|
-
if (!response.ok) throw new Error('Protected API request failed.');
|
|
151
|
-
return response.json();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export async function saveResource(value: string): Promise<unknown> {
|
|
155
|
-
// Mutations are single-attempt unless YOUR operation owns a server-enforced
|
|
156
|
-
// idempotency contract (see Retry policy).
|
|
157
|
-
const response = await fetchWithDpop(
|
|
158
|
-
{ session, apis: [{ origin: backendOrigin, replayNamespace: 'my-app-api' }] },
|
|
159
|
-
`${backendOrigin}/api/resource`,
|
|
160
|
-
{ method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: value, retry: 'idempotent' },
|
|
161
|
-
);
|
|
162
|
-
if (!response.ok) throw new Error('Protected API request failed.');
|
|
163
|
-
return response.json();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export async function signOut(): Promise<void> {
|
|
167
|
-
await session.logout();
|
|
168
|
-
}
|
|
169
170
|
```
|
|
170
171
|
|
|
172
|
+
Apply the callback/bootstrap, API and logout flow above to this session.
|
|
171
173
|
Cookie refresh uses one same-origin Web Lock per vault scope plus
|
|
172
174
|
`BroadcastChannel` winner-token delivery: a tab adopts a live peer token for up
|
|
173
175
|
to 180ms, otherwise it performs its own serialized refresh with the current
|
|
@@ -177,17 +179,157 @@ fresh proof. Web Locks coordinate tabs of one frontend origin, storage
|
|
|
177
179
|
partition, and vault scope — they are not a backend refresh lease, so other
|
|
178
180
|
frontends, devices, or processes can still race upstream refresh families.
|
|
179
181
|
|
|
182
|
+
## Lifecycle concurrency and cleanup
|
|
183
|
+
|
|
184
|
+
Use one session instance per tab and vault mount. In **both transports**,
|
|
185
|
+
`login`, `exchange`, `refresh`, and `logout` run in invocation order. A logout
|
|
186
|
+
queued behind refresh uses the rotated handle/cookie. Cookie operations also
|
|
187
|
+
hold the scope's Web Lock across their HTTP work and credential publication.
|
|
188
|
+
Body queues belong to the instance; they do not coordinate other instances or
|
|
189
|
+
copied tab handles.
|
|
190
|
+
|
|
191
|
+
Concurrent `refresh()` callers receive the **same Promise** until a call to
|
|
192
|
+
`login`, `exchange`, `logout`, `clear`, or `resetKey` starts a new batch. The first caller's
|
|
193
|
+
`rejectedToken` applies to that batch. A refresh invoked after a queued logout
|
|
194
|
+
waits behind it instead of joining the earlier refresh. Read-only `getKey()`
|
|
195
|
+
checks can run alongside the queue and one another; only their still-current
|
|
196
|
+
failure cleanup enters the mutation queue. They neither start refresh nor
|
|
197
|
+
erase a pending login merely because exchange has not finished.
|
|
198
|
+
|
|
199
|
+
Every asynchronous session operation keeps its entry-time authority. `clear`,
|
|
200
|
+
`resetKey`, a terminal auth failure, and `dispose` supersede older work, including queued
|
|
201
|
+
operations. Stale results cannot publish tokens/handles, change cookie
|
|
202
|
+
generation metadata, clear a newer login, or navigate. A stale call rejects
|
|
203
|
+
with **`DPOP_OPERATION_SUPERSEDED`**, or **`DPOP_SESSION_DISPOSED`** after
|
|
204
|
+
disposal; both have `requiresLogin: false`. Follow the current session flow
|
|
205
|
+
rather than clearing auth or starting another login in response to these
|
|
206
|
+
errors. Key reads and background peer callbacks also lose authority when a
|
|
207
|
+
newer auth state is published. A new `login()` intent additionally supersedes
|
|
208
|
+
older peer callbacks immediately, including while that login is waiting in the
|
|
209
|
+
lifecycle queue or for the cookie Web Lock.
|
|
210
|
+
|
|
211
|
+
| Method | Effect |
|
|
212
|
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
213
|
+
| `logout()` | Ordered server logout, then frontend cleanup. Revokes vault refresh state. |
|
|
214
|
+
| `clear()` | Immediately drops this context's memory token, vault nonces, body handle and pending-login marker, and aborts its in-flight vault HTTP. Keeps the persistent key. In cookie mode, its Promise waits for the Web Lock, inactive-generation write and peer notification. It does not call the backend or remove the HttpOnly cookie. |
|
|
215
|
+
| `resetKey()` | Explicit local key recovery. Immediately invalidates the same local work/auth as `clear`, then atomically deletes **this scope's key and cookie-session metadata**. Cookie mode uses the shared Web Lock and notifies affected peers. Repeating on absent records is harmless. Creates no key and makes no backend request; await it, then invoke `login()` explicitly. |
|
|
216
|
+
| `dispose()` | Immediately closes this context, drops its memory token/vault nonces, aborts owned vault HTTP, settles peer waits, removes listeners/timers and closes its channel. Keeps the previously persisted key, handle, pending-login marker and committed cookie metadata so a replacement context can bootstrap normally. Later calls reject. |
|
|
217
|
+
|
|
218
|
+
Clear/reset/dispose invalidate pending cookie-version and session-owned key
|
|
219
|
+
transactions as well as memory publication. After clear or reset, unsolicited peer messages cannot restore this
|
|
220
|
+
context; an explicit successful exchange/refresh must reestablish auth. Peer
|
|
221
|
+
waits end within 180ms, or immediately on local invalidation/disposal, and a
|
|
222
|
+
late peer lookup cannot publish after that wait has ended. Same-origin tabs
|
|
223
|
+
still reuse the current generation's live winner token; key-loss, expiry and
|
|
224
|
+
recognition checks remain mandatory. Reset notifications are advisory: peers
|
|
225
|
+
check that **both** scoped records are still absent and that their local
|
|
226
|
+
key/generation matches the deletion. A delayed notification or old-binding
|
|
227
|
+
failure cannot clear a replacement login, including a fresh key created before
|
|
228
|
+
cookie metadata is written. Concurrent resets share their pending removal until
|
|
229
|
+
another lifecycle mutation intervenes; repeating after completion also succeeds
|
|
230
|
+
when both records are already absent.
|
|
231
|
+
|
|
232
|
+
Clear/token/reset messages are advisory until their shared key/generation state
|
|
233
|
+
is checked. A failed peer recognition or coordination lookup leaves local auth
|
|
234
|
+
and pending login intact. An admitted token's key-validation failure still
|
|
235
|
+
invalidates a matching current local credential; explicit credential operations
|
|
236
|
+
continue to report genuine key/storage and terminal session errors.
|
|
237
|
+
|
|
238
|
+
**Limits:** already-started storage/crypto and noncooperative custom transport
|
|
239
|
+
work may still finish, but superseded results cannot publish. Vault HTTP waits
|
|
240
|
+
are bounded as described below; storage, recognition hooks, and time waiting
|
|
241
|
+
for other Web Lock owners are outside that HTTP deadline. Cleanup cannot undo
|
|
242
|
+
a server mutation or a browser-applied `Set-Cookie`. Await `clear()` or
|
|
243
|
+
`resetKey()` before disposing when its persistence work must finish. Lifecycle cleanup also
|
|
244
|
+
does not cancel caller-owned API requests or revoke already-issued stateless
|
|
245
|
+
JWTs. Coordination remains limited to one frontend origin/storage
|
|
246
|
+
partition/scope.
|
|
247
|
+
|
|
248
|
+
## HTTP bounds and cancellation
|
|
249
|
+
|
|
250
|
+
All vault `login`/`exchange`/`refresh`/`logout` attempts use these session options:
|
|
251
|
+
|
|
252
|
+
| Option | Default | Valid values / scope |
|
|
253
|
+
| ------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
254
|
+
| `requestTimeoutMs` | **15,000 milliseconds** | Integer from 1 through 2,147,483,647. One deadline from fetch invocation through response headers, full body read, and JSON parsing. |
|
|
255
|
+
| `maxResponseBytes` | **1,048,576 bytes (1 MiB)** | Positive safe integer. Counts bytes actually read from the fetch body stream on every HTTP status, after browser HTTP decompression. |
|
|
256
|
+
|
|
257
|
+
Invalid options throw `TypeError` at session construction; zero and infinity
|
|
258
|
+
do not disable the bounds. Content-Length is not trusted to measure the body:
|
|
259
|
+
missing, understated, overstated, or malformed values do not change the byte
|
|
260
|
+
count. UTF-8 characters count by their bytes, not JavaScript string length.
|
|
261
|
+
Custom fetch implementations must expose a standard streaming `Response.body`.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
265
|
+
import type { OidcVaultDpopSessionOptions } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
266
|
+
|
|
267
|
+
const options: OidcVaultDpopSessionOptions = {
|
|
268
|
+
backendOrigin: 'https://api.example.com',
|
|
269
|
+
requestTimeoutMs: 15_000,
|
|
270
|
+
maxResponseBytes: 1_048_576,
|
|
271
|
+
};
|
|
272
|
+
const session = createOidcVaultDpopSession(options);
|
|
273
|
+
|
|
274
|
+
// Supply the caller's AbortController.signal (for example, on page teardown).
|
|
275
|
+
export async function loadProfile(signal: AbortSignal): Promise<unknown> {
|
|
276
|
+
const response = await fetchWithDpop(
|
|
277
|
+
{ session, apis: [{ origin: options.backendOrigin, replayNamespace: 'my-app-api' }] },
|
|
278
|
+
`${options.backendOrigin}/api/profile`,
|
|
279
|
+
{ signal },
|
|
280
|
+
);
|
|
281
|
+
return response.json();
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The HTTP deadline excludes the lifecycle queue, key/proof/recognition work and
|
|
286
|
+
credential publication. An allowed nonce retry gets its own deadline, with
|
|
287
|
+
the existing two-attempt vault budget. A trickling body cannot reset it.
|
|
288
|
+
Deadline or byte overflow rejects with fixed **`DPOP_REQUEST_TIMEOUT`** or
|
|
289
|
+
**`DPOP_RESPONSE_TOO_LARGE`**, both `requiresLogin: false`. These failures do
|
|
290
|
+
not themselves clear auth, and release refresh/FIFO/Web Lock ownership for a
|
|
291
|
+
later explicit operation. Malformed JSON still follows the existing route
|
|
292
|
+
response-contract error handling.
|
|
293
|
+
|
|
294
|
+
Clear/reset/invalidation/disposal abort the session-owned request and stop waiting
|
|
295
|
+
even if an injected fetch or stream ignores AbortSignal. Cancellation and
|
|
296
|
+
reader cleanup are best effort: their failure or noncompletion cannot delay
|
|
297
|
+
settlement or replace its primary outcome. Late responses are discarded and
|
|
298
|
+
late rejections are observed. Timers cannot run in suspended tabs or preempt
|
|
299
|
+
synchronous JavaScript; elapsed time is also checked after decoding/parsing
|
|
300
|
+
before accepting a result.
|
|
301
|
+
|
|
302
|
+
For `fetchWithDpop`, a pre-aborted signal starts no refresh, key lookup, proof
|
|
303
|
+
or network work. Later cancellation rejects with `signal.reason` promptly,
|
|
304
|
+
including while waiting for shared refresh/key work. It cancels only this
|
|
305
|
+
caller's wait; another waiter's shared refresh continues and may publish its
|
|
306
|
+
result. Proof work checks cancellation before signing, including after the
|
|
307
|
+
access-token digest; already-started Web Crypto may finish. Standalone
|
|
308
|
+
`createDpopProof` also accepts `DpopProofInput.signal`. API requests receive the
|
|
309
|
+
caller signal; the returned response/body belongs to the caller. The vault
|
|
310
|
+
timeout/byte options do not bound API bodies. Cleanup of a discarded API
|
|
311
|
+
challenge response cannot stall its permitted retry or replace its outcome.
|
|
312
|
+
|
|
313
|
+
**Uncertain commit / retry:** no timeout, cancellation, byte-limit, or network
|
|
314
|
+
failure automatically retries a mutation. The server may already have
|
|
315
|
+
consumed an exchange code, rotated a refresh handle, or completed logout, and
|
|
316
|
+
the browser may already have applied `Set-Cookie`. Aborting does not roll that
|
|
317
|
+
back. An explicit retry is not evidence that the first attempt failed; a lost
|
|
318
|
+
body-mode rotation can make the retained old handle unusable and require fresh
|
|
319
|
+
login. Use the operation's server-side recovery/idempotency contract when
|
|
320
|
+
deciding what to do next.
|
|
321
|
+
|
|
180
322
|
## Backend pairing
|
|
181
323
|
|
|
182
324
|
Configure the vault mount this client calls. `backendOrigin` must be the exact
|
|
183
|
-
public origin (scheme + host + port); `basePath` must
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
325
|
+
public origin (scheme + host + port); `basePath` must match the public mount.
|
|
326
|
+
Unreserved path escapes, dot segments and trailing slashes normalize; the key
|
|
327
|
+
scope, proof `htu` and request URL all derive from that path. Use `127.0.0.1`
|
|
328
|
+
consistently in loopback development:
|
|
187
329
|
`localhost` is a different origin and a different key scope.
|
|
188
330
|
|
|
189
331
|
Backend options that must agree with this client (authoritative wire contract:
|
|
190
|
-
[`express-oidc-vault` README](
|
|
332
|
+
[`express-oidc-vault` README](https://github.com/egose/web-ts-toolkit/blob/main/packages/express-oidc-vault/README.md)):
|
|
191
333
|
|
|
192
334
|
- `basePath`: the same mount (e.g. `/auth/oidc/body` with `sessionTransport:
|
|
193
335
|
'body'`, `/auth/oidc/cookie` with `'cookie'`).
|
|
@@ -204,6 +346,41 @@ Backend options that must agree with this client (authoritative wire contract:
|
|
|
204
346
|
- The API's `replayNamespace` must equal the `replayNamespace` configured in
|
|
205
347
|
`fetchWithDpop` `apis` entries.
|
|
206
348
|
|
|
349
|
+
### Bound credential response
|
|
350
|
+
|
|
351
|
+
Exchange/refresh must return exact `tokenType: 'DPoP'` and a compact signed
|
|
352
|
+
JWT: three nonempty canonical unpadded base64url segments, a nonempty signing
|
|
353
|
+
`alg` other than `none`, and no `b64: false` payload. Its `cnf` must be exactly
|
|
354
|
+
`{ jkt }`, with the canonical RFC 7638 SHA-256 thumbprint of the persistent
|
|
355
|
+
browser key (43 base64url characters, including canonical final bits).
|
|
356
|
+
Additional confirmation fields/methods are rejected.
|
|
357
|
+
|
|
358
|
+
JWT `exp` is mandatory: a nonnegative safe integer in epoch **seconds**, with
|
|
359
|
+
safe `exp * 1000` milliseconds. Response `expiresIn` is nonnegative
|
|
360
|
+
safe-integer **seconds**; its millisecond conversion and addition to the
|
|
361
|
+
current nonnegative safe-integer epoch-millisecond clock must also be safe.
|
|
362
|
+
Invalid or overflowing values are rejected even when a shorter deadline could
|
|
363
|
+
otherwise hide them. The effective `DpopAccessToken.expiresAt` is the earlier
|
|
364
|
+
of the response deadline and JWT expiry. These are **response-contract decode
|
|
365
|
+
checks, not JWT authentication**: each API must independently verify the JWT
|
|
366
|
+
signature/expiry/issuer/audience and the original-key request proof.
|
|
367
|
+
|
|
368
|
+
`expiresIn: 0` remains valid so a rotated body handle can be retained. The
|
|
369
|
+
returned credential may already be expired; `getAccessToken()` is a snapshot,
|
|
370
|
+
not a guarantee of a live token. `fetchWithDpop` checks the deadline per attempt
|
|
371
|
+
and after asynchronous proof work. A nonlive refresh result stops with
|
|
372
|
+
`DPOP_ACCESS_TOKEN_EXPIRED` (`requiresLogin: false`), preserves session handles,
|
|
373
|
+
and starts no additional refresh loop; a later explicit refresh may recover.
|
|
374
|
+
Cookie peer adoption clamps the envelope deadline to JWT expiry and requires
|
|
375
|
+
the final result to be strictly in the future. Cookie responses and peer
|
|
376
|
+
tokens must contain no `sessionId` property.
|
|
377
|
+
|
|
378
|
+
Invalid direct responses produce `INVALID_BOUND_CREDENTIAL_RESPONSE`; invalid
|
|
379
|
+
or expired peer tokens are ignored. The paired server enforces the same bound
|
|
380
|
+
JWT profile before claim mapping in **both** validator methods after JOSE
|
|
381
|
+
verification. Generic unbound JWTs retain optional-expiry compatibility, and
|
|
382
|
+
unbound issuers can still return opaque tokens; this client expects bound JWTs.
|
|
383
|
+
|
|
207
384
|
### CORS
|
|
208
385
|
|
|
209
386
|
The backend must answer credentialed cross-origin browser calls:
|
|
@@ -224,13 +401,64 @@ Vault Origin/Referer checks run independently of CORS. Cookie-authenticated
|
|
|
224
401
|
When nonces are enabled, a missing/expired nonce returns fixed
|
|
225
402
|
`OIDC_VAULT_USE_DPOP_NONCE`: **HTTP 400 at vault POSTs**
|
|
226
403
|
(login/exchange/refresh/logout), **HTTP 401 with `DPoP error="use_dpop_nonce"`
|
|
227
|
-
at protected APIs**.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
404
|
+
at protected APIs**.
|
|
405
|
+
|
|
406
|
+
- Vault POSTs allow **one nonce retry** when that code arrives on HTTP 400 or
|
|
407
|
+
401 with a valid `DPoP-Nonce`. A second nonce challenge throws
|
|
408
|
+
**`DPOP_NONCE_RETRY_EXHAUSTED`**, `requiresLogin: false`, with its HTTP status.
|
|
409
|
+
Existing auth/pending-login state is retained for explicit recovery; starting
|
|
410
|
+
a new `login()` still performs its documented frontend reset. A first
|
|
411
|
+
challenge with no usable nonce returns the fixed non-terminal
|
|
412
|
+
`OIDC_VAULT_USE_DPOP_NONCE` error without a retry.
|
|
413
|
+
- API calls allow one nonce retry **only when their retry policy permits it**
|
|
414
|
+
(see [Retry policy](#retry-policy-fetchwithdpop)). Only an HTTP-401 DPoP
|
|
415
|
+
`error="invalid_token"` challenge allows `session.refresh()` and a further
|
|
416
|
+
attempt, at most once per invocation. An initial refresh for absent/expired
|
|
417
|
+
credentials uses that same refresh budget. There are at most three API HTTP
|
|
418
|
+
attempts: the original plus one nonce retry and one refresh retry. The final
|
|
419
|
+
API response, including a repeated challenge, is returned to the caller.
|
|
420
|
+
|
|
421
|
+
Each permitted attempt mints a fresh proof/`jti`/signature with the current
|
|
422
|
+
`iat` and API-only `ath`; vault proofs never carry `ath`. Replayed bodies keep
|
|
423
|
+
their original snapshot. Generic 401s, proof errors, 403s, 503s, and network
|
|
424
|
+
failures do not trigger API refresh or extra attempts.
|
|
425
|
+
|
|
426
|
+
**Learning is independent of retry permission.** Both helpers remember a
|
|
427
|
+
valid nonce from the selected trusted response, including success and final
|
|
428
|
+
error responses. An unsafe POST still makes exactly one attempt and returns
|
|
429
|
+
its challenge; the **next explicit invocation** can send the learned nonce.
|
|
430
|
+
Learning from a header never authorizes resubmission of the current mutation.
|
|
431
|
+
|
|
432
|
+
### Nonce cache and challenge parsing
|
|
433
|
+
|
|
434
|
+
`DpopNonceCache` holds at most **64 `(protection space, key thumbprint)`
|
|
435
|
+
tuples** per instance. A successful `remember` renews that tuple's write order;
|
|
436
|
+
insertion at capacity evicts the **oldest remembered** tuple. Reads do not
|
|
437
|
+
promote entries, and rejected values neither replace an entry nor change
|
|
438
|
+
eviction order. Vault mounts and API `(origin, replayNamespace)` spaces remain
|
|
439
|
+
separate. API calls share a default cache through their session, even when a
|
|
440
|
+
new context wrapper is passed each time; a supplied `nonces` cache uses the
|
|
441
|
+
same limits and isolation.
|
|
442
|
+
|
|
443
|
+
Accepted nonce headers contain **one opaque value of 1–512 visible ASCII
|
|
444
|
+
characters**, excluding whitespace, double quotes, backslashes, and commas.
|
|
445
|
+
The client never trims, splits, unquotes, or decodes the exposed header value.
|
|
446
|
+
Comma-containing values, including Fetch-coalesced duplicate fields (even
|
|
447
|
+
identical ones), are rejected without overwriting a usable nonce. This is the
|
|
448
|
+
OAuth nonce-character syntax restricted to an unambiguous single header value.
|
|
449
|
+
There is **no client-invented TTL**: nonce expiry is determined by the server's
|
|
450
|
+
next challenge, not by interpreting its private nonce format. Cache eviction
|
|
451
|
+
can likewise require a new challenge.
|
|
452
|
+
|
|
453
|
+
The API `WWW-Authenticate` parser accepts auth-parameters in any order, token
|
|
454
|
+
or quoted values, and other authentication schemes in the same field. Only
|
|
455
|
+
**one unambiguous DPoP challenge** can trigger recovery. Bearer errors and
|
|
456
|
+
quoted descriptions never supply a DPoP error. Duplicate parameter names
|
|
457
|
+
(case-insensitive), multiple DPoP challenges, malformed/empty list members,
|
|
458
|
+
or a field longer than **8,192 characters** cause no refresh/retry. A valid
|
|
459
|
+
nonce header can still be learned independently of that decision. Vault
|
|
460
|
+
status/nonce fields are captured before body reads, so an injected response
|
|
461
|
+
cannot change the recovery decision while its body is awaited.
|
|
234
462
|
|
|
235
463
|
## Storage
|
|
236
464
|
|
|
@@ -248,6 +476,10 @@ opener-cloned `sessionStorage` is not a shared-refresh strategy. No tokens or
|
|
|
248
476
|
handles enter `localStorage` or IndexedDB. Crypto runs outside the readwrite
|
|
249
477
|
transaction; export of the private key always fails by construction.
|
|
250
478
|
|
|
479
|
+
The historical database name `oidc-vault-dpop-example-v1` and version-1 schema
|
|
480
|
+
are retained so installed keys survive upgrades. Use `resetKey()` for scoped
|
|
481
|
+
recovery rather than deleting the entire origin's database.
|
|
482
|
+
|
|
251
483
|
## Key loss and errors
|
|
252
484
|
|
|
253
485
|
Key loss or key change clears frontend auth and requires a fresh login —
|
|
@@ -255,14 +487,91 @@ Key loss or key change clears frontend auth and requires a fresh login —
|
|
|
255
487
|
missing key; exchange/refresh/API/logout read the existing key and compare its
|
|
256
488
|
binding (`cnf.jkt` decode check plus key-equality check). There is no Bearer
|
|
257
489
|
fallback and no ephemeral-key fallback. Storage/crypto failures stop the
|
|
258
|
-
operation;
|
|
490
|
+
operation; error handling never silently deletes or creates a key.
|
|
491
|
+
|
|
492
|
+
### Explicit recovery from a corrupt key
|
|
493
|
+
|
|
494
|
+
An **existing but unusable** record (for example, a mismatched private/public
|
|
495
|
+
pair) also fails validation during `login()`. Retrying login or calling
|
|
496
|
+
`clear()` cannot repair it: clear deliberately keeps the key. Offer an explicit
|
|
497
|
+
recovery action that calls `resetKey()` and awaits it before starting login:
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
import { createOidcVaultDpopSession } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
501
|
+
|
|
502
|
+
const session = createOidcVaultDpopSession({
|
|
503
|
+
backendOrigin: 'https://api.example.com',
|
|
504
|
+
basePath: '/auth/oidc/body',
|
|
505
|
+
sessionTransport: 'body',
|
|
506
|
+
});
|
|
259
507
|
|
|
260
|
-
|
|
508
|
+
// A user-invoked recovery button when the persisted browser key is unusable.
|
|
509
|
+
export async function recoverBrowserKey(): Promise<void> {
|
|
510
|
+
await session.resetKey();
|
|
511
|
+
await session.login('/callback?transport=body');
|
|
512
|
+
// The redirect callback calls session.exchange(code), as in the quickstart.
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Reset bypasses key validation and removes both scoped IndexedDB records in one
|
|
517
|
+
guarded transaction. It leaves other vault mounts/backend origins and their
|
|
518
|
+
installed keys/metadata intact. The next explicit login persists a fresh
|
|
519
|
+
non-extractable key using the same atomic first-key-wins path across tabs.
|
|
520
|
+
IndexedDB still has to be available; reset does not repair denied/unavailable
|
|
521
|
+
browser storage. Recovery is never automatic on API, refresh, logout, proof,
|
|
522
|
+
nonce, timeout, or recognition errors.
|
|
523
|
+
|
|
524
|
+
**Reset is local-only recovery.** Other sessions using the removed key need a
|
|
525
|
+
fresh login; cookie peers are notified under the shared lock, and body tabs
|
|
526
|
+
detect the lost/changed key on their next credential operation. The HttpOnly
|
|
527
|
+
cookie is not removed, old server sessions remain until **explicit revocation
|
|
528
|
+
or expiry**, and issued stateless JWTs are not revoked. If the key is still
|
|
529
|
+
usable and server sign-out is wanted, call `logout()` before resetting it.
|
|
530
|
+
An already-started API request, browser-applied cookie, server mutation, or
|
|
531
|
+
previously obtained `CryptoKey` reference cannot be undone by local deletion.
|
|
532
|
+
API requests remain caller-owned; use their AbortSignal for local cancellation.
|
|
533
|
+
|
|
534
|
+
### Error classification
|
|
535
|
+
|
|
536
|
+
Package-generated `OidcVaultDpopClientError`s carry a stable `code`, a fixed message (never
|
|
261
537
|
keys, credentials, fingerprints, or URLs), `requiresLogin`, and an optional
|
|
262
538
|
HTTP `status`. Treat `requiresLogin === true` as "route to sign-in"; enrolled
|
|
263
539
|
recognition changes surface as `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` (clear
|
|
264
540
|
auth, start fresh POST login, no automatic re-enrollment).
|
|
265
541
|
|
|
542
|
+
Vault JSON errors use an explicit code allowlist rather than treating every
|
|
543
|
+
HTTP 401 as logout. These **terminal** server codes clear local auth and
|
|
544
|
+
pending login, invalidate cookie coordination metadata, and require sign-in:
|
|
545
|
+
|
|
546
|
+
| Code | Meaning |
|
|
547
|
+
| ---------------------------------------- | --------------------------------------------------------------------- |
|
|
548
|
+
| `OIDC_VAULT_INVALID_SESSION` | Session is missing, expired, or no longer usable. |
|
|
549
|
+
| `OIDC_VAULT_MISSING_SESSION_ID` | The body handle or session cookie is missing (HTTP 400). |
|
|
550
|
+
| `OIDC_VAULT_MALFORMED_SESSION_COOKIE` | The session cookie is malformed (HTTP 400). |
|
|
551
|
+
| `OIDC_VAULT_INVALID_EXCHANGE_CODE` | The exchange code is invalid/expired (HTTP 400); begin a fresh login. |
|
|
552
|
+
| `OIDC_VAULT_INVALID_BROWSER_BINDING` | The login transaction's browser-cookie binding failed. |
|
|
553
|
+
| `OIDC_VAULT_DEVICE_BINDING_REQUIRED` | The session needs a device-bound login. |
|
|
554
|
+
| `OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED` | Enrolled recognition changed or is absent. |
|
|
555
|
+
|
|
556
|
+
Proof/nonce errors (`OIDC_VAULT_INVALID_DPOP_PROOF`,
|
|
557
|
+
`OIDC_VAULT_DPOP_REQUIRED`, `OIDC_VAULT_USE_DPOP_NONCE`) are **non-terminal**,
|
|
558
|
+
even on HTTP 401. Replay-store unavailability, upstream/provider failures, and
|
|
559
|
+
request/configuration failures also retain auth. An invalid recognition input
|
|
560
|
+
(`OIDC_VAULT_INVALID_FINGERPRINT`) is non-terminal; it differs from a confirmed
|
|
561
|
+
enrollment mismatch. Non-terminal does not promise that a retry will succeed
|
|
562
|
+
or authorize one automatically; use the operation's recovery contract.
|
|
563
|
+
|
|
564
|
+
Unknown codes, including object-prototype names, become fixed
|
|
565
|
+
**`OIDC_REQUEST_FAILED` / `The OIDC request failed.`**, `requiresLogin: false`;
|
|
566
|
+
server codes/messages are never copied into that fallback. The HTTP status is
|
|
567
|
+
retained. Local key loss, binding/token-type violations and invalid bound
|
|
568
|
+
credential responses remain terminal. API HTTP failures are still caller-owned
|
|
569
|
+
`Response`s, with automatic recovery limited to the challenge rules above.
|
|
570
|
+
|
|
571
|
+
Construction can also throw `TypeError` for invalid options; caller abort
|
|
572
|
+
rejects with `signal.reason`. Do not assume every thrown value is a package
|
|
573
|
+
error or automatically start login for a non-terminal failure.
|
|
574
|
+
|
|
266
575
|
## Credentials matrix
|
|
267
576
|
|
|
268
577
|
`credentials` is owned by the helpers; callers must not set it.
|
|
@@ -277,12 +586,98 @@ Cookie JSON always omits `sessionId`. Every request uses `redirect: 'error'`
|
|
|
277
586
|
and `cache: 'no-store'`. Vault POSTs present no access token; API calls send
|
|
278
587
|
`Authorization: DPoP <JWT>` plus a fresh same-key proof with `ath`.
|
|
279
588
|
|
|
589
|
+
## Multiple services behind a gateway
|
|
590
|
+
|
|
591
|
+
Use `DpopApi.pathPrefix` to select each service's credential policy and backend
|
|
592
|
+
`replayNamespace` on one public origin. Pass the **full public URL**, including
|
|
593
|
+
the service path; `pathPrefix` selects the scope without adding or stripping a
|
|
594
|
+
mount. Bootstrap the session with login/exchange or refresh as above.
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
import { createOidcVaultDpopSession, fetchWithDpop } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
598
|
+
import type { DpopApi, DpopFetchContext } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
599
|
+
|
|
600
|
+
const origin = 'https://gateway.example.com';
|
|
601
|
+
const session = createOidcVaultDpopSession({
|
|
602
|
+
backendOrigin: origin,
|
|
603
|
+
basePath: '/auth/oidc/body',
|
|
604
|
+
sessionTransport: 'body',
|
|
605
|
+
});
|
|
606
|
+
const apis: readonly DpopApi[] = [
|
|
607
|
+
{ origin, pathPrefix: '/orders', replayNamespace: 'orders-api' },
|
|
608
|
+
// This billing service also uses an application cookie.
|
|
609
|
+
{ origin, pathPrefix: '/billing', replayNamespace: 'billing-api', credentials: 'include' },
|
|
610
|
+
];
|
|
611
|
+
const gateway: DpopFetchContext = { session, apis };
|
|
612
|
+
|
|
613
|
+
export function loadOrders(): Promise<Response> {
|
|
614
|
+
return fetchWithDpop(gateway, `${origin}/orders?limit=20`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function loadInvoices(): Promise<Response> {
|
|
618
|
+
return fetchWithDpop(gateway, `${origin}/billing/invoices`);
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Selection is **case-sensitive at normalized path-segment boundaries**:
|
|
623
|
+
`/orders` matches `/orders`, `/orders/`, and `/orders/42`, but neither
|
|
624
|
+
`/orders-evil` nor `/orders%2Fevil`. The longest matching prefix wins, regardless
|
|
625
|
+
of array order. For example, `/orders/admin` takes precedence over `/orders`;
|
|
626
|
+
`/orders/admin-evil` still uses `/orders`. Trailing prefix slashes are removed,
|
|
627
|
+
so `/orders/` and `/orders` define the same scope.
|
|
628
|
+
|
|
629
|
+
**Omission and root are origin-wide.** Leaving out `pathPrefix` preserves the
|
|
630
|
+
existing whole-origin behavior. Explicit `/`, or a prefix that normalizes to
|
|
631
|
+
`/`, also permits credentials on **every path** at that origin. A root entry is
|
|
632
|
+
a fallback for paths outside more-specific entries. Without such a fallback,
|
|
633
|
+
an unmatched path fails before access-token lookup, refresh, key lookup,
|
|
634
|
+
signing, or network work.
|
|
635
|
+
|
|
636
|
+
Matching, the outbound pathname, and proof `htu` use the same normalization:
|
|
637
|
+
unreserved percent escapes (`A–Z`, `a–z`, digits, `-._~`) decode, remaining
|
|
638
|
+
escape hex digits uppercase, and dot segments collapse. Thus `/ord%65rs` is
|
|
639
|
+
sent as `/orders`, and `/orders/%2e%2e/billing` can only use the billing scope
|
|
640
|
+
(or an origin-wide fallback). **Reserved escapes stay encoded**: `%2F`, `%5C`,
|
|
641
|
+
`%3F`, and `%23` never become separators, a new authority, query, or fragment.
|
|
642
|
+
Double-encoded escapes are not decoded again. Query order, duplicate query
|
|
643
|
+
parameters, escaping, and fragment data are retained in the URL passed to
|
|
644
|
+
fetch. HTTP omits the fragment; the proof omits both query and fragment. The
|
|
645
|
+
same uppercase method is used for the proof and every outbound attempt.
|
|
646
|
+
|
|
647
|
+
Every invocation validates **all** entries, including nonmatching ones, before
|
|
648
|
+
auth work. An origin must contain only scheme/host/port and an optional root
|
|
649
|
+
slash, with HTTPS except for loopback development. A prefix must be an absolute
|
|
650
|
+
path without a URL/`//` authority-like prefix, query, fragment, whitespace,
|
|
651
|
+
backslash, control character, or malformed escape. `replayNamespace` must be
|
|
652
|
+
1–128 printable ASCII characters; `credentials` accepts only `omit` (default)
|
|
653
|
+
or `include`. Duplicate normalized `(origin, pathPrefix)` scopes are rejected
|
|
654
|
+
even if their policies are identical: this includes omission versus `/`,
|
|
655
|
+
trailing slashes, equivalent unreserved escapes, and equivalent dot segments.
|
|
656
|
+
Targets must already be absolute HTTP(S) URLs; configuring a prefix never
|
|
657
|
+
makes a relative, userinfo-bearing, or malformed target usable.
|
|
658
|
+
|
|
659
|
+
The target and selected origin/path/namespace/**credentials** are snapshotted
|
|
660
|
+
before asynchronous auth work. All attempts and nonce learning for that
|
|
661
|
+
invocation keep that snapshot, including across refresh/key waits. Caller
|
|
662
|
+
configuration changes apply to later invocations. Nonces retain the existing
|
|
663
|
+
`['api', normalizedOrigin, replayNamespace]` + signing-key-thumbprint tuple:
|
|
664
|
+
the two services above use independent nonce spaces, while paths deliberately
|
|
665
|
+
using the same origin/namespace share a nonce space. Path selection leaves the
|
|
666
|
+
nonce/refresh retry budgets and single-attempt mutation policy below intact.
|
|
667
|
+
|
|
668
|
+
| Failure | Fixed client code (`requiresLogin: false`) |
|
|
669
|
+
| ---------------------------------------------------- | ------------------------------------------ |
|
|
670
|
+
| No matching origin/path scope | `DPOP_API_OUT_OF_SCOPE` |
|
|
671
|
+
| Invalid policy fields or duplicate normalized scopes | `INVALID_DPOP_API_CONFIG` |
|
|
672
|
+
| Malformed URL, origin syntax, or prefix | `INVALID_DPOP_TARGET` |
|
|
673
|
+
| Configured non-loopback HTTP origin | `INSECURE_DPOP_ORIGIN` |
|
|
674
|
+
|
|
280
675
|
## Retry policy (`fetchWithDpop`)
|
|
281
676
|
|
|
282
|
-
Scoped `fetch` for
|
|
677
|
+
Scoped `fetch` for the configured API origin/path subtrees: it owns
|
|
283
678
|
`Authorization`/`DPoP` (caller-set auth headers throw
|
|
284
679
|
`DPOP_AUTH_HEADER_OVERRIDE`), mints a fresh proof per attempt, and accepts only
|
|
285
|
-
exact origin matches with a valid `replayNamespace`.
|
|
680
|
+
exact origin matches with a matching `pathPrefix` and valid `replayNamespace`.
|
|
286
681
|
|
|
287
682
|
| Method | Default attempts | Opt-in |
|
|
288
683
|
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
@@ -373,25 +768,40 @@ singleton, and no client router — the callback is the same page reading
|
|
|
373
768
|
## API reference
|
|
374
769
|
|
|
375
770
|
- `createOidcVaultDpopSession(options)` → `OidcVaultDpopSession`
|
|
376
|
-
(`login`/`exchange`/`refresh`/`logout`/`getAccessToken`/`getKey`/`clear`/`dispose`).
|
|
771
|
+
(`login`/`exchange`/`refresh`/`logout`/`getAccessToken`/`getKey`/`clear`/`resetKey`/`dispose`).
|
|
377
772
|
Options: `backendOrigin`, `basePath` (default `/auth/oidc`),
|
|
378
773
|
`sessionTransport` (`'body'` default | `'cookie'`), optional `fingerprint`,
|
|
774
|
+
`requestTimeoutMs` (default 15,000 ms), `maxResponseBytes` (default 1 MiB),
|
|
379
775
|
injectable `fetch`/`navigate`/`now` for tests.
|
|
380
776
|
- `fetchWithDpop({ session, apis, fetch?, nonces?, now? }, input, options?)` →
|
|
381
777
|
`Promise<Response>`. `apis: DpopApi[]` pins `{ origin, replayNamespace,
|
|
382
|
-
credentials? }
|
|
778
|
+
pathPrefix?, credentials? }`; longest normalized segment-prefix match wins.
|
|
383
779
|
- `getOrCreateDpopKey(scope, { create? })`, `createDpopProof(key, input)`,
|
|
384
780
|
`DpopNonceCache`, `createDeviceFingerprint(source, { headerName? })`,
|
|
385
781
|
`fingerprintJsSignalSource(load)`, `OidcVaultDpopClientError`.
|
|
386
782
|
- Types: `DpopKey`, `DpopKeyScope`, `DpopProofInput`, `DpopAccessToken`,
|
|
387
783
|
`DpopFetchContext`, `DpopFetchOptions`, `DpopApi`,
|
|
388
|
-
`OidcVaultDpopSession
|
|
784
|
+
`OidcVaultDpopSession`, `OidcVaultDpopSessionOptions`, `DeviceFingerprint`, `DeviceFingerprintOptions`,
|
|
389
785
|
`DeviceFingerprintSignalSource`, `FingerprintJsAgent`. Wire DTOs stay
|
|
390
|
-
single-sourced from `@web-ts-toolkit/express-oidc-vault`
|
|
786
|
+
single-sourced from the pure `@web-ts-toolkit/express-oidc-vault/wire` entry
|
|
787
|
+
at build time; no backend import survives in client declarations/JavaScript.
|
|
788
|
+
|
|
789
|
+
## Upgrade notes
|
|
790
|
+
|
|
791
|
+
| Area | Consumer action / delivered contract |
|
|
792
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
793
|
+
| Node and types | Use **Node >=22.12.0** for CJS `require(esm)`. Browser declarations now need only DOM/ES libs; remove compensating Express/Node ambient types if your app has no other use for them. Backend root type imports and `req.auth` remain available. |
|
|
794
|
+
| Bound JWT profile | Issuers must return canonical compact signed JWTs, exact `{ cnf: { jkt } }`, mandatory safe-integer `exp` seconds and safe millisecond lifetimes. Extra confirmation fields, missing/unsafe expiry or overflowing arithmetic are rejected. The generic server unbound/opaque contracts remain compatible. `expiresIn: 0` preserves rotated handles but supplies no live reusable API/peer token. |
|
|
795
|
+
| Lifecycle ordering | Both transports now use FIFO mutations. Refresh sharing ends at intervening mutations. Failed advisory peer reads preserve auth; older peer callbacks yield to a newer login intent, even while queued. Handle `DPOP_OPERATION_SUPERSEDED` / `DPOP_SESSION_DISPOSED` as non-terminal; follow the newer flow. Await clear/reset before teardown when persistence must finish. |
|
|
796
|
+
| HTTP limits and cancellation | Vault attempts default to **15,000 ms / 1 MiB**, including injected fetches. Configure larger finite budgets when warranted. Injected responses need a byte stream, not only a custom `json()`. API/proof `signal` stops waits and later attempts; discarded-response cleanup cannot replace the outcome. Lost mutation responses remain uncertain commits. |
|
|
797
|
+
| Nonces and retries | Success/final/single-attempt responses now teach the bounded 64-tuple cache. Reordered unambiguous DPoP challenges work; repeated vault challenges give `DPOP_NONCE_RETRY_EXHAUSTED`. Learning a nonce never authorizes a blanket mutation retry. |
|
|
798
|
+
| Error handling | Use known `code` / `requiresLogin`, not every 401. Proof/nonce/transient errors retain auth; terminal session/exchange errors include HTTP 400. Unknown server codes become fixed `OIDC_REQUEST_FAILED`. |
|
|
799
|
+
| Gateway scopes | Existing origin-only entries remain origin-wide. Add `pathPrefix` to restrict forwarding; replace duplicate normalized scopes with distinct prefixes or one shared policy. Outbound pathname now follows proof normalization, including unreserved escapes; query data is preserved. Origin fields reject paths or query/fragment markers even when parsing would erase them (`/orders/..`, `?`, `#`). |
|
|
800
|
+
| Key recovery | `OidcVaultDpopSession` includes **`resetKey(): Promise<void>`**; add it to typed mocks. Use explicit reset-then-login for unusable records. No DB rename/migration or implicit replacement occurs. Reset is local recovery, with old sessions/JWTs remaining until revocation/expiry. |
|
|
391
801
|
|
|
392
802
|
Worked end-to-end example (Vite SPA + Express vault/API + local OIDC
|
|
393
803
|
provider, both transports, real-browser evidence):
|
|
394
|
-
[`apps/oidc-vault-dpop-example`](
|
|
804
|
+
[`apps/oidc-vault-dpop-example`](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md).
|
|
395
805
|
Migrating from the pre-publish copy: replace `copy src/auth/` + `pnpm add
|
|
396
806
|
jose idb` with `pnpm add @web-ts-toolkit/oidc-vault-dpop-client`, rewrite
|
|
397
807
|
`from './auth…'` imports to the package root, and keep `basePath` on the exact
|