@web-ts-toolkit/oidc-vault-dpop-client 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 CHANGED
@@ -179,17 +179,80 @@ fresh proof. Web Locks coordinate tabs of one frontend origin, storage
179
179
  partition, and vault scope — they are not a backend refresh lease, so other
180
180
  frontends, devices, or processes can still race upstream refresh families.
181
181
 
182
+ ## Provider logout navigation
183
+
184
+ With server `logoutNavigation` enabled, call **`logoutAndNavigate(): Promise<void>`**
185
+ from a user action. It confirms local logout, clears old frontend auth and invokes
186
+ the synchronous `navigate(url)` adapter (default `location.assign`) inside lifecycle
187
+ authority. `logout()` remains local-only. No return-URL/prepare API is needed.
188
+
189
+ ```ts
190
+ import {
191
+ createOidcVaultDpopSession,
192
+ type OidcVaultLogoutNavigationInput,
193
+ type OidcVaultLogoutNavigationResult,
194
+ } from '@web-ts-toolkit/oidc-vault-dpop-client';
195
+
196
+ const session = createOidcVaultDpopSession({
197
+ backendOrigin: 'https://api.example.com',
198
+ basePath: '/auth/oidc',
199
+ sessionTransport: 'body', // Or the matching cookie mount and 'cookie'.
200
+ });
201
+ export const signOutThroughProvider = (): Promise<void> => session.logoutAndNavigate();
202
+ // Pure wire types are also bundled at the client root; no backend install needed.
203
+ export type NavigationRequest = OidcVaultLogoutNavigationInput;
204
+ export type NavigationResponse = OidcVaultLogoutNavigationResult;
205
+ ```
206
+
207
+ The existing persistent key plus body handle or **active matching cookie metadata**
208
+ suffices after reload, with an absent or expired memory JWT. It does not refresh,
209
+ create a replacement key, send fingerprint/Authorization/`ath`, or put API tokens
210
+ in query strings. POST uses the same finite HTTP bounds and nonce cache, including
211
+ learned success nonces: at most one recognized challenge retry with a fresh proof/JTI.
212
+ Body credentials are `omit` with `{ sessionId }`; cookie credentials are `include`
213
+ with `{}` under the scope Web Lock. Strict server Origin/Referer checks apply in
214
+ both transports; allow the frontend via `trustedOrigins` and expose `DPoP-Nonce` in CORS.
215
+
216
+ The client accepts only the exact absolute pinned backend continuation path,
217
+ bare or followed by one literal `?ticket=lnt_<43 canonical base64url characters>`.
218
+ No upstream URL/hint enters initiation JSON. A confirmed `loggedOut: true` clears
219
+ token, handle, pending login, nonces and peer adoption, and invalidates cookie
220
+ metadata/notifies peers even if the URL is invalid; that case rejects with fixed
221
+ nonterminal **`INVALID_LOGOUT_NAVIGATION_RESPONSE`** and never navigates. Unconfirmed,
222
+ malformed, oversized, timed-out or lost responses leave the server commit uncertain,
223
+ retain the existing failure policy and never automatically retry or navigate.
224
+ Cleanup/adapter failure rejects after memory clear, without credential restoration.
225
+
226
+ Concurrent clicks share the **same pending Promise** through FIFO, nonce retry,
227
+ cleanup and one adapter invocation. An earlier refresh publishes rotation first;
228
+ a later refresh cannot resurrect credentials. Refresh does not split that click
229
+ batch. Later login/exchange/local logout/clear/resetKey/dispose immediately withdraws
230
+ navigation intent, including a login still queued for the FIFO/lock. A started
231
+ POST may finish old-state cleanup before that login, but rejects as superseded
232
+ instead of navigating. No completed ticket URL is cached; another click after
233
+ cleanup requires login. Cancellation cannot undo server commit or navigation
234
+ already handed to the browser.
235
+
236
+ The server revokes locally before optional provider work. Missing endpoints,
237
+ discovery/ticket failures and missing/spent/expired tickets select its fixed
238
+ configured fallback. Tickets default to 60 seconds (server range 1–120 seconds),
239
+ are single-use, and retain only the final upstream hint URL server-side until
240
+ consume/expiry/cleanup. Navigation/history/proxy logs and the IdP request still
241
+ expose their respective URLs; avoid logging them. After upstream handoff, provider
242
+ availability/termination/return is not guaranteed. Stateless JWTs remain valid
243
+ until expiry unless the application adds revocation.
244
+
182
245
  ## Lifecycle concurrency and cleanup
183
246
 
184
247
  Use one session instance per tab and vault mount. In **both transports**,
185
- `login`, `exchange`, `refresh`, and `logout` run in invocation order. A logout
248
+ `login`, `exchange`, `refresh`, `logout`, and `logoutAndNavigate` run in invocation order. A logout
186
249
  queued behind refresh uses the rotated handle/cookie. Cookie operations also
187
250
  hold the scope's Web Lock across their HTTP work and credential publication.
188
251
  Body queues belong to the instance; they do not coordinate other instances or
189
252
  copied tab handles.
190
253
 
191
254
  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
255
+ `login`, `exchange`, `logout`, `logoutAndNavigate`, `clear`, or `resetKey` starts a new batch. The first caller's
193
256
  `rejectedToken` applies to that batch. A refresh invoked after a queued logout
194
257
  waits behind it instead of joining the earlier refresh. Read-only `getKey()`
195
258
  checks can run alongside the queue and one another; only their still-current
@@ -208,12 +271,13 @@ newer auth state is published. A new `login()` intent additionally supersedes
208
271
  older peer callbacks immediately, including while that login is waiting in the
209
272
  lifecycle queue or for the cookie Web Lock.
210
273
 
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. |
274
+ | Method | Effect |
275
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
276
+ | `logout()` | Ordered server logout, then frontend cleanup. Revokes vault refresh state. |
277
+ | `logoutAndNavigate()` | Ordered proof-authorized server logout, confirmed cleanup, then provider/fallback navigation; coalesced clicks and withdrawable navigation intent. Requires server opt-in. |
278
+ | `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. |
279
+ | `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. |
280
+ | `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
281
 
218
282
  Clear/reset/dispose invalidate pending cookie-version and session-owned key
219
283
  transactions as well as memory publication. After clear or reset, unsolicited peer messages cannot restore this
@@ -247,7 +311,7 @@ partition/scope.
247
311
 
248
312
  ## HTTP bounds and cancellation
249
313
 
250
- All vault `login`/`exchange`/`refresh`/`logout` attempts use these session options:
314
+ All vault `login`/`exchange`/`refresh`/`logout`/`logoutAndNavigate` attempts use these session options:
251
315
 
252
316
  | Option | Default | Valid values / scope |
253
317
  | ------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
@@ -343,6 +407,9 @@ Backend options that must agree with this client (authoritative wire contract:
343
407
  - `deviceBinding.mode: 'required'` for bound flows, with a shared nonce secret
344
408
  when nonces are enabled (`nonce: { secret, lifetimeSeconds }`, ≥32 random
345
409
  bytes shared by instances of that protection space).
410
+ - `logoutNavigation: { fallbackRedirectUri }` enables provider navigation logout;
411
+ configure/register `postLogoutRedirectUri` for provider return. Built-in stores
412
+ supply ticket capabilities; custom stores need both optional ticket methods.
346
413
  - The API's `replayNamespace` must equal the `replayNamespace` configured in
347
414
  `fetchWithDpop` `apis` entries.
348
415
 
@@ -576,11 +643,12 @@ error or automatically start login for a non-terminal failure.
576
643
 
577
644
  `credentials` is owned by the helpers; callers must not set it.
578
645
 
579
- | Operation | Body transport | Cookie transport |
580
- | -------------------- | ---------------------------------------- | ------------------------------------------------------------------ |
581
- | `login` / `exchange` | `include` (temporary transaction cookie) | `include` (temporary transaction cookie) |
582
- | `refresh` / `logout` | `omit` + `{ sessionId }` JSON body | `include` + `{}` JSON body (no `sessionId`) |
583
- | `fetchWithDpop` API | `omit` default | `omit` default (`include` only for an explicitly cookie-using API) |
646
+ | Operation | Body transport | Cookie transport |
647
+ | ------------------------ | ---------------------------------------- | ------------------------------------------------------------------ |
648
+ | `login` / `exchange` | `include` (temporary transaction cookie) | `include` (temporary transaction cookie) |
649
+ | `refresh` / `logout` | `omit` + `{ sessionId }` JSON body | `include` + `{}` JSON body (no `sessionId`) |
650
+ | `logoutAndNavigate` POST | `omit` + `{ sessionId }` JSON body | `include` + `{}` JSON body (no `sessionId`) |
651
+ | `fetchWithDpop` API | `omit` default | `omit` default (`include` only for an explicitly cookie-using API) |
584
652
 
585
653
  Cookie JSON always omits `sessionId`. Every request uses `redirect: 'error'`
586
654
  and `cache: 'no-store'`. Vault POSTs present no access token; API calls send
@@ -768,7 +836,7 @@ singleton, and no client router — the callback is the same page reading
768
836
  ## API reference
769
837
 
770
838
  - `createOidcVaultDpopSession(options)` → `OidcVaultDpopSession`
771
- (`login`/`exchange`/`refresh`/`logout`/`getAccessToken`/`getKey`/`clear`/`resetKey`/`dispose`).
839
+ (`login`/`exchange`/`refresh`/`logout`/`logoutAndNavigate`/`getAccessToken`/`getKey`/`clear`/`resetKey`/`dispose`).
772
840
  Options: `backendOrigin`, `basePath` (default `/auth/oidc`),
773
841
  `sessionTransport` (`'body'` default | `'cookie'`), optional `fingerprint`,
774
842
  `requestTimeoutMs` (default 15,000 ms), `maxResponseBytes` (default 1 MiB),
@@ -785,9 +853,17 @@ pathPrefix?, credentials? }`; longest normalized segment-prefix match wins.
785
853
  `DeviceFingerprintSignalSource`, `FingerprintJsAgent`. Wire DTOs stay
786
854
  single-sourced from the pure `@web-ts-toolkit/express-oidc-vault/wire` entry
787
855
  at build time; no backend import survives in client declarations/JavaScript.
856
+ - Pure root DTOs: `OidcVaultLogoutNavigationInput`, `OidcVaultLogoutNavigationResult`.
788
857
 
789
858
  ## Upgrade notes
790
859
 
860
+ Provider logout is additive: enable the paired server's `logoutNavigation` and
861
+ replace custom logout proof/fetch/redirect handling with `session.logoutAndNavigate()`.
862
+ Keep `logout()` for local-only behavior. Add `logoutAndNavigate` to typed session
863
+ mocks. `navigate` now handles both login and logout URLs synchronously; do not
864
+ defer its actual navigation beyond the call, where lifecycle authority no longer
865
+ protects it. Existing persistent keys and the database schema are retained.
866
+
791
867
  | Area | Consumer action / delivered contract |
792
868
  | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
793
869
  | 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. |
package/index.d.mts CHANGED
@@ -85,6 +85,37 @@ interface OidcVaultUserProfile {
85
85
  preferredUsername?: string;
86
86
  [key: string]: unknown;
87
87
  }
88
+ /** Successful local logout; stateless application JWTs require separate revocation or expiry. */
89
+ interface OidcVaultLogoutResult {
90
+ /**
91
+ * Successful logout commits revocation before upstream work and clears the
92
+ * session cookie under cookie transport. Built-in live handles and unexpired
93
+ * aliases authenticate the surviving lineage's provider/original binding;
94
+ * wrong identity, missing/wrong/stale/replayed proof, or disabled acceptance
95
+ * of a bound lineage fails without deleting or clearing. No currently live
96
+ * target is idempotent success without deletion or proof reservation. Aliases
97
+ * grant revocation only, not refresh authentication or upstream redirects.
98
+ * Old custom bearer stores without revocation context retain legacy alias
99
+ * deletion behavior. Stateless application access tokens are not invalidated.
100
+ * Local-only logout (`redirect` unset or `false`) never contacts the provider.
101
+ * Redirected logout (`redirect: true`)
102
+ * treats the upstream end-session redirect as best-effort: when provider
103
+ * discovery fails or no `endSessionEndpoint` is available, the route still
104
+ * returns this local success. Discovery errors reach `onError` without
105
+ * undoing revocation or skipping the live session's `onLogout` notification.
106
+ */
107
+ loggedOut: true;
108
+ }
109
+ /** JSON-only POST <basePath>/logout/navigation. Proof belongs in DPoP, with no access token or ath. */
110
+ interface OidcVaultLogoutNavigationInput {
111
+ /** Required nonempty handle in body transport; ignored in cookie transport, which selects only the configured cookie. */
112
+ sessionId?: string;
113
+ }
114
+ /** Confirmed local logout, not an IdP delivery receipt. Never contains an upstream token-bearing URL. */
115
+ interface OidcVaultLogoutNavigationResult extends OidcVaultLogoutResult {
116
+ /** Absolute pinned backend continuation URL, bare or with one opaque one-use ticket. Navigate at top level; do not fetch the provider redirect. */
117
+ navigationUrl: string;
118
+ }
88
119
  /** Vault handle transport; independent of DPoP mode. Default: body. */
89
120
  type OidcVaultSessionTransport = 'body' | 'cookie';
90
121
 
@@ -157,8 +188,8 @@ interface OidcVaultDpopSessionOptions {
157
188
  maxResponseBytes?: number;
158
189
  /** Default global fetch. Injected responses must expose a standard streaming body. */
159
190
  fetch?: typeof globalThis.fetch;
160
- /** Login redirect after authoritative initiation; default location.assign. */
161
- navigate?: (authorizationUrl: string) => void;
191
+ /** Synchronous login/logout navigation inside lifecycle authority; default location.assign. */
192
+ navigate?: (url: string) => void;
162
193
  /** Epoch-millisecond clock; default Date.now. Credential deadline arithmetic must remain safe. */
163
194
  now?: () => number;
164
195
  }
@@ -181,6 +212,14 @@ interface OidcVaultDpopSession {
181
212
  }): Promise<DpopAccessToken>;
182
213
  /** FIFO: an earlier refresh publishes its rotated handle before logout uses it. Revokes server refresh state. */
183
214
  logout(): Promise<void>;
215
+ /**
216
+ * Proof-authorized local logout followed by top-level provider/fallback navigation.
217
+ * Requires server logoutNavigation opt-in and the existing key/handle or active
218
+ * cookie metadata, not a live access JWT. Concurrent clicks share one Promise.
219
+ * Later login/exchange/logout/clear/resetKey/dispose withdraw navigation intent.
220
+ * Invalid navigation URLs still clear confirmed logout state, then reject.
221
+ */
222
+ logoutAndNavigate(): Promise<void>;
184
223
  /** Memory snapshot only; may already be expired, including a valid expiresIn: 0 response. */
185
224
  getAccessToken(): DpopAccessToken | undefined;
186
225
  /**
@@ -367,4 +406,4 @@ declare class OidcVaultDpopClientError extends Error {
367
406
  constructor(code: string, message: string, requiresLogin?: boolean, status?: number);
368
407
  }
369
408
 
370
- export { type DeviceFingerprint, type DeviceFingerprintOptions, type DeviceFingerprintSignalSource, type DpopAccessToken, type DpopApi, type DpopFetchContext, type DpopFetchOptions, type DpopKey, type DpopKeyScope, DpopNonceCache, type DpopProofInput, type FingerprintJsAgent, OidcVaultDpopClientError, type OidcVaultDpopSession, type OidcVaultDpopSessionOptions, createDeviceFingerprint, createDpopProof, createOidcVaultDpopSession, fetchWithDpop, fingerprintJsSignalSource, getOrCreateDpopKey };
409
+ export { type DeviceFingerprint, type DeviceFingerprintOptions, type DeviceFingerprintSignalSource, type DpopAccessToken, type DpopApi, type DpopFetchContext, type DpopFetchOptions, type DpopKey, type DpopKeyScope, DpopNonceCache, type DpopProofInput, type FingerprintJsAgent, OidcVaultDpopClientError, type OidcVaultDpopSession, type OidcVaultDpopSessionOptions, type OidcVaultLogoutNavigationInput, type OidcVaultLogoutNavigationResult, createDeviceFingerprint, createDpopProof, createOidcVaultDpopSession, fetchWithDpop, fingerprintJsSignalSource, getOrCreateDpopKey };
package/index.d.ts CHANGED
@@ -85,6 +85,37 @@ interface OidcVaultUserProfile {
85
85
  preferredUsername?: string;
86
86
  [key: string]: unknown;
87
87
  }
88
+ /** Successful local logout; stateless application JWTs require separate revocation or expiry. */
89
+ interface OidcVaultLogoutResult {
90
+ /**
91
+ * Successful logout commits revocation before upstream work and clears the
92
+ * session cookie under cookie transport. Built-in live handles and unexpired
93
+ * aliases authenticate the surviving lineage's provider/original binding;
94
+ * wrong identity, missing/wrong/stale/replayed proof, or disabled acceptance
95
+ * of a bound lineage fails without deleting or clearing. No currently live
96
+ * target is idempotent success without deletion or proof reservation. Aliases
97
+ * grant revocation only, not refresh authentication or upstream redirects.
98
+ * Old custom bearer stores without revocation context retain legacy alias
99
+ * deletion behavior. Stateless application access tokens are not invalidated.
100
+ * Local-only logout (`redirect` unset or `false`) never contacts the provider.
101
+ * Redirected logout (`redirect: true`)
102
+ * treats the upstream end-session redirect as best-effort: when provider
103
+ * discovery fails or no `endSessionEndpoint` is available, the route still
104
+ * returns this local success. Discovery errors reach `onError` without
105
+ * undoing revocation or skipping the live session's `onLogout` notification.
106
+ */
107
+ loggedOut: true;
108
+ }
109
+ /** JSON-only POST <basePath>/logout/navigation. Proof belongs in DPoP, with no access token or ath. */
110
+ interface OidcVaultLogoutNavigationInput {
111
+ /** Required nonempty handle in body transport; ignored in cookie transport, which selects only the configured cookie. */
112
+ sessionId?: string;
113
+ }
114
+ /** Confirmed local logout, not an IdP delivery receipt. Never contains an upstream token-bearing URL. */
115
+ interface OidcVaultLogoutNavigationResult extends OidcVaultLogoutResult {
116
+ /** Absolute pinned backend continuation URL, bare or with one opaque one-use ticket. Navigate at top level; do not fetch the provider redirect. */
117
+ navigationUrl: string;
118
+ }
88
119
  /** Vault handle transport; independent of DPoP mode. Default: body. */
89
120
  type OidcVaultSessionTransport = 'body' | 'cookie';
90
121
 
@@ -157,8 +188,8 @@ interface OidcVaultDpopSessionOptions {
157
188
  maxResponseBytes?: number;
158
189
  /** Default global fetch. Injected responses must expose a standard streaming body. */
159
190
  fetch?: typeof globalThis.fetch;
160
- /** Login redirect after authoritative initiation; default location.assign. */
161
- navigate?: (authorizationUrl: string) => void;
191
+ /** Synchronous login/logout navigation inside lifecycle authority; default location.assign. */
192
+ navigate?: (url: string) => void;
162
193
  /** Epoch-millisecond clock; default Date.now. Credential deadline arithmetic must remain safe. */
163
194
  now?: () => number;
164
195
  }
@@ -181,6 +212,14 @@ interface OidcVaultDpopSession {
181
212
  }): Promise<DpopAccessToken>;
182
213
  /** FIFO: an earlier refresh publishes its rotated handle before logout uses it. Revokes server refresh state. */
183
214
  logout(): Promise<void>;
215
+ /**
216
+ * Proof-authorized local logout followed by top-level provider/fallback navigation.
217
+ * Requires server logoutNavigation opt-in and the existing key/handle or active
218
+ * cookie metadata, not a live access JWT. Concurrent clicks share one Promise.
219
+ * Later login/exchange/logout/clear/resetKey/dispose withdraw navigation intent.
220
+ * Invalid navigation URLs still clear confirmed logout state, then reject.
221
+ */
222
+ logoutAndNavigate(): Promise<void>;
184
223
  /** Memory snapshot only; may already be expired, including a valid expiresIn: 0 response. */
185
224
  getAccessToken(): DpopAccessToken | undefined;
186
225
  /**
@@ -367,4 +406,4 @@ declare class OidcVaultDpopClientError extends Error {
367
406
  constructor(code: string, message: string, requiresLogin?: boolean, status?: number);
368
407
  }
369
408
 
370
- export { type DeviceFingerprint, type DeviceFingerprintOptions, type DeviceFingerprintSignalSource, type DpopAccessToken, type DpopApi, type DpopFetchContext, type DpopFetchOptions, type DpopKey, type DpopKeyScope, DpopNonceCache, type DpopProofInput, type FingerprintJsAgent, OidcVaultDpopClientError, type OidcVaultDpopSession, type OidcVaultDpopSessionOptions, createDeviceFingerprint, createDpopProof, createOidcVaultDpopSession, fetchWithDpop, fingerprintJsSignalSource, getOrCreateDpopKey };
409
+ export { type DeviceFingerprint, type DeviceFingerprintOptions, type DeviceFingerprintSignalSource, type DpopAccessToken, type DpopApi, type DpopFetchContext, type DpopFetchOptions, type DpopKey, type DpopKeyScope, DpopNonceCache, type DpopProofInput, type FingerprintJsAgent, OidcVaultDpopClientError, type OidcVaultDpopSession, type OidcVaultDpopSessionOptions, type OidcVaultLogoutNavigationInput, type OidcVaultLogoutNavigationResult, createDeviceFingerprint, createDpopProof, createOidcVaultDpopSession, fetchWithDpop, fingerprintJsSignalSource, getOrCreateDpopKey };
package/index.js CHANGED
@@ -690,11 +690,17 @@ var createOidcVaultDpopSession = (options) => {
690
690
  const cookieBindings = /* @__PURE__ */ new WeakMap();
691
691
  let refreshPromise;
692
692
  let resetPromise;
693
+ let navigationPromise;
694
+ let navigationIntent = 0;
693
695
  const lifecycle = createSessionLifecycle();
694
696
  let publication = 0;
695
697
  let loginIntent = 0;
696
698
  let acceptPeerTokens = true;
697
699
  const peerWaiters = /* @__PURE__ */ new Set();
700
+ const withdrawNavigation = () => {
701
+ navigationIntent++;
702
+ navigationPromise = void 0;
703
+ };
698
704
  const readStorage = (key) => {
699
705
  try {
700
706
  const value = sessionStorage.getItem(key);
@@ -732,6 +738,7 @@ var createOidcVaultDpopSession = (options) => {
732
738
  };
733
739
  const invalidateLocal = (clearPendingLogin = true) => {
734
740
  const authority = lifecycle.invalidate();
741
+ withdrawNavigation();
735
742
  refreshPromise = void 0;
736
743
  resetPromise = void 0;
737
744
  clearLocal(clearPendingLogin);
@@ -811,7 +818,7 @@ var createOidcVaultDpopSession = (options) => {
811
818
  let nonceRetried = false;
812
819
  for (; ; ) {
813
820
  authority.assertCurrent();
814
- const recognition = route === "logout" ? {} : await fingerprint?.headers() ?? {};
821
+ const recognition = route === "logout" || route === "logout/navigation" ? {} : await fingerprint?.headers() ?? {};
815
822
  const currentKey = await existingKey(authority, jkt);
816
823
  authority.assertCurrent();
817
824
  const proof = await createDpopProof(currentKey, {
@@ -1055,6 +1062,7 @@ var createOidcVaultDpopSession = (options) => {
1055
1062
  sessionTransport,
1056
1063
  async login(returnTo) {
1057
1064
  const authority = lifecycle.capture();
1065
+ withdrawNavigation();
1058
1066
  loginIntent++;
1059
1067
  refreshPromise = void 0;
1060
1068
  resetPromise = void 0;
@@ -1100,6 +1108,7 @@ var createOidcVaultDpopSession = (options) => {
1100
1108
  },
1101
1109
  async exchange(code) {
1102
1110
  const authority = lifecycle.capture();
1111
+ withdrawNavigation();
1103
1112
  refreshPromise = void 0;
1104
1113
  resetPromise = void 0;
1105
1114
  return operation(authority, async () => {
@@ -1166,6 +1175,7 @@ var createOidcVaultDpopSession = (options) => {
1166
1175
  },
1167
1176
  async logout() {
1168
1177
  const authority = lifecycle.capture();
1178
+ withdrawNavigation();
1169
1179
  refreshPromise = void 0;
1170
1180
  resetPromise = void 0;
1171
1181
  await operation(authority, async () => {
@@ -1182,6 +1192,56 @@ var createOidcVaultDpopSession = (options) => {
1182
1192
  await invalidateCookie(authority, key.jkt);
1183
1193
  });
1184
1194
  },
1195
+ logoutAndNavigate() {
1196
+ const authority = lifecycle.capture();
1197
+ if (navigationPromise) return navigationPromise;
1198
+ const intent = navigationIntent;
1199
+ const navigation = lifecycle.capture(() => navigationIntent === intent);
1200
+ refreshPromise = void 0;
1201
+ resetPromise = void 0;
1202
+ const pending = operation(authority, async () => {
1203
+ navigation.assertCurrent();
1204
+ const key = await authenticatedKey(authority);
1205
+ authority.assertCurrent();
1206
+ if (channel) {
1207
+ const version = await readCookieSessionVersion(scope.id);
1208
+ authority.assertCurrent();
1209
+ if (!version?.active || version.jkt !== key.jkt) throw loginRequired();
1210
+ cookieBindings.set(authority, { ...version });
1211
+ }
1212
+ const handle = sessionTransport === "body" ? readStorage(storageKey) : void 0;
1213
+ if (sessionTransport === "body" && (!handle?.sessionId || handle.jkt !== key.jkt)) throw loginRequired();
1214
+ const { value } = await post(
1215
+ authority,
1216
+ "logout/navigation",
1217
+ handle ? { sessionId: handle.sessionId } : {},
1218
+ key
1219
+ );
1220
+ authority.assertCurrent();
1221
+ const invalidResponse = () => new OidcVaultDpopClientError(
1222
+ "INVALID_LOGOUT_NAVIGATION_RESPONSE",
1223
+ "The logout navigation response is invalid."
1224
+ );
1225
+ if (typeof value !== "object" || value === null || !("loggedOut" in value) || value.loggedOut !== true)
1226
+ throw invalidResponse();
1227
+ clearLocal();
1228
+ await invalidateCookie(authority, key.jkt);
1229
+ authority.assertCurrent();
1230
+ navigation.assertCurrent();
1231
+ const bare = `${scope.backendOrigin}${scope.basePath === "/" ? "" : scope.basePath}/logout/navigation`;
1232
+ const url = "navigationUrl" in value ? value.navigationUrl : void 0;
1233
+ if (typeof url !== "string" || url !== bare && (url.length !== bare.length + "?ticket=".length + 47 || !url.startsWith(`${bare}?ticket=`) || !/^lnt_[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(url.slice(`${bare}?ticket=`.length))))
1234
+ throw invalidResponse();
1235
+ navigation.assertCurrent();
1236
+ navigate(url);
1237
+ });
1238
+ navigationPromise = pending;
1239
+ void pending.finally(() => {
1240
+ if (navigationPromise === pending) navigationPromise = void 0;
1241
+ }).catch(() => {
1242
+ });
1243
+ return pending;
1244
+ },
1185
1245
  getAccessToken: () => token,
1186
1246
  async getKey() {
1187
1247
  const authority = stateAuthority();
@@ -1236,6 +1296,7 @@ var createOidcVaultDpopSession = (options) => {
1236
1296
  },
1237
1297
  dispose() {
1238
1298
  lifecycle.dispose();
1299
+ withdrawNavigation();
1239
1300
  publication++;
1240
1301
  refreshPromise = void 0;
1241
1302
  resetPromise = void 0;
package/index.mjs CHANGED
@@ -658,11 +658,17 @@ var createOidcVaultDpopSession = (options) => {
658
658
  const cookieBindings = /* @__PURE__ */ new WeakMap();
659
659
  let refreshPromise;
660
660
  let resetPromise;
661
+ let navigationPromise;
662
+ let navigationIntent = 0;
661
663
  const lifecycle = createSessionLifecycle();
662
664
  let publication = 0;
663
665
  let loginIntent = 0;
664
666
  let acceptPeerTokens = true;
665
667
  const peerWaiters = /* @__PURE__ */ new Set();
668
+ const withdrawNavigation = () => {
669
+ navigationIntent++;
670
+ navigationPromise = void 0;
671
+ };
666
672
  const readStorage = (key) => {
667
673
  try {
668
674
  const value = sessionStorage.getItem(key);
@@ -700,6 +706,7 @@ var createOidcVaultDpopSession = (options) => {
700
706
  };
701
707
  const invalidateLocal = (clearPendingLogin = true) => {
702
708
  const authority = lifecycle.invalidate();
709
+ withdrawNavigation();
703
710
  refreshPromise = void 0;
704
711
  resetPromise = void 0;
705
712
  clearLocal(clearPendingLogin);
@@ -779,7 +786,7 @@ var createOidcVaultDpopSession = (options) => {
779
786
  let nonceRetried = false;
780
787
  for (; ; ) {
781
788
  authority.assertCurrent();
782
- const recognition = route === "logout" ? {} : await fingerprint?.headers() ?? {};
789
+ const recognition = route === "logout" || route === "logout/navigation" ? {} : await fingerprint?.headers() ?? {};
783
790
  const currentKey = await existingKey(authority, jkt);
784
791
  authority.assertCurrent();
785
792
  const proof = await createDpopProof(currentKey, {
@@ -1023,6 +1030,7 @@ var createOidcVaultDpopSession = (options) => {
1023
1030
  sessionTransport,
1024
1031
  async login(returnTo) {
1025
1032
  const authority = lifecycle.capture();
1033
+ withdrawNavigation();
1026
1034
  loginIntent++;
1027
1035
  refreshPromise = void 0;
1028
1036
  resetPromise = void 0;
@@ -1068,6 +1076,7 @@ var createOidcVaultDpopSession = (options) => {
1068
1076
  },
1069
1077
  async exchange(code) {
1070
1078
  const authority = lifecycle.capture();
1079
+ withdrawNavigation();
1071
1080
  refreshPromise = void 0;
1072
1081
  resetPromise = void 0;
1073
1082
  return operation(authority, async () => {
@@ -1134,6 +1143,7 @@ var createOidcVaultDpopSession = (options) => {
1134
1143
  },
1135
1144
  async logout() {
1136
1145
  const authority = lifecycle.capture();
1146
+ withdrawNavigation();
1137
1147
  refreshPromise = void 0;
1138
1148
  resetPromise = void 0;
1139
1149
  await operation(authority, async () => {
@@ -1150,6 +1160,56 @@ var createOidcVaultDpopSession = (options) => {
1150
1160
  await invalidateCookie(authority, key.jkt);
1151
1161
  });
1152
1162
  },
1163
+ logoutAndNavigate() {
1164
+ const authority = lifecycle.capture();
1165
+ if (navigationPromise) return navigationPromise;
1166
+ const intent = navigationIntent;
1167
+ const navigation = lifecycle.capture(() => navigationIntent === intent);
1168
+ refreshPromise = void 0;
1169
+ resetPromise = void 0;
1170
+ const pending = operation(authority, async () => {
1171
+ navigation.assertCurrent();
1172
+ const key = await authenticatedKey(authority);
1173
+ authority.assertCurrent();
1174
+ if (channel) {
1175
+ const version = await readCookieSessionVersion(scope.id);
1176
+ authority.assertCurrent();
1177
+ if (!version?.active || version.jkt !== key.jkt) throw loginRequired();
1178
+ cookieBindings.set(authority, { ...version });
1179
+ }
1180
+ const handle = sessionTransport === "body" ? readStorage(storageKey) : void 0;
1181
+ if (sessionTransport === "body" && (!handle?.sessionId || handle.jkt !== key.jkt)) throw loginRequired();
1182
+ const { value } = await post(
1183
+ authority,
1184
+ "logout/navigation",
1185
+ handle ? { sessionId: handle.sessionId } : {},
1186
+ key
1187
+ );
1188
+ authority.assertCurrent();
1189
+ const invalidResponse = () => new OidcVaultDpopClientError(
1190
+ "INVALID_LOGOUT_NAVIGATION_RESPONSE",
1191
+ "The logout navigation response is invalid."
1192
+ );
1193
+ if (typeof value !== "object" || value === null || !("loggedOut" in value) || value.loggedOut !== true)
1194
+ throw invalidResponse();
1195
+ clearLocal();
1196
+ await invalidateCookie(authority, key.jkt);
1197
+ authority.assertCurrent();
1198
+ navigation.assertCurrent();
1199
+ const bare = `${scope.backendOrigin}${scope.basePath === "/" ? "" : scope.basePath}/logout/navigation`;
1200
+ const url = "navigationUrl" in value ? value.navigationUrl : void 0;
1201
+ if (typeof url !== "string" || url !== bare && (url.length !== bare.length + "?ticket=".length + 47 || !url.startsWith(`${bare}?ticket=`) || !/^lnt_[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(url.slice(`${bare}?ticket=`.length))))
1202
+ throw invalidResponse();
1203
+ navigation.assertCurrent();
1204
+ navigate(url);
1205
+ });
1206
+ navigationPromise = pending;
1207
+ void pending.finally(() => {
1208
+ if (navigationPromise === pending) navigationPromise = void 0;
1209
+ }).catch(() => {
1210
+ });
1211
+ return pending;
1212
+ },
1153
1213
  getAccessToken: () => token,
1154
1214
  async getKey() {
1155
1215
  const authority = stateAuthority();
@@ -1204,6 +1264,7 @@ var createOidcVaultDpopSession = (options) => {
1204
1264
  },
1205
1265
  dispose() {
1206
1266
  lifecycle.dispose();
1267
+ withdrawNavigation();
1207
1268
  publication++;
1208
1269
  refreshPromise = void 0;
1209
1270
  resetPromise = void 0;
package/llms.txt CHANGED
@@ -14,6 +14,7 @@ createDeviceFingerprint, fingerprintJsSignalSource, OidcVaultDpopClientError.
14
14
  Full root type list and member contracts: README API reference + index.d.mts.
15
15
  Both declaration conditions are DOM-only (types:[], skipLibCheck:false).
16
16
  Wire DTOs come from express-oidc-vault/wire at build time and are bundled here.
17
+ Root DTOs: OidcVaultLogoutNavigationInput, OidcVaultLogoutNavigationResult (DOM-only).
17
18
 
18
19
  Session: createOidcVaultDpopSession({backendOrigin,basePath,sessionTransport})
19
20
  defaults to /auth/oidc + body. login -> exchange(code) -> API/refresh -> logout.
@@ -36,6 +37,19 @@ server revocation; reset leaves old sessions/JWTs until revocation/expiry.
36
37
  DPOP_OPERATION_SUPERSEDED / DPOP_SESSION_DISPOSED are non-terminal; stale work
37
38
  cannot publish/navigate/clear newer auth. See README's cleanup/recovery tables.
38
39
 
40
+ Provider logout: server logoutNavigation opt-in + session.logoutAndNavigate():Promise<void>.
41
+ Existing key/handle or active matching cookie metadata; absent/expired JWT works,
42
+ no refresh/fingerprint/Authorization/ath. Same HTTP bounds/nonce cache, one fresh-proof retry.
43
+ Exact pinned backend bare or canonical ticket URL only; no API token in GET/IdP URL in JSON.
44
+ Confirmed loggedOut:true clears old state even on INVALID_LOGOUT_NAVIGATION_RESPONSE.
45
+ Unconfirmed/lost responses retain uncertainty, no navigation or automatic mutation retry.
46
+ Clicks share one Promise/adapter call; FIFO preserves rotation. Login/exchange/local logout/
47
+ clear/reset/dispose withdraw old navigation intent immediately, including queued login.
48
+ Synchronous navigate adapter handles login AND logout inside authority; do not defer handoff.
49
+ Single-use ticket (default60s, 1..120s) -> provider or fixed fallback; replay falls back.
50
+ Hint URL retained server-side until consume/expiry/cleanup; navigation URLs remain log/history
51
+ exposures. After IdP handoff its availability/return is not guaranteed. logout() stays local.
52
+
39
53
  HTTP: requestTimeoutMs=15000 ms per vault headers/body/JSON attempt;
40
54
  maxResponseBytes=1048576 streamed bytes. Bounds exclude queue/storage/key/hooks/
41
55
  other locks. Timeout/size errors retain auth. API/proof signal stops only this
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/oidc-vault-dpop-client",
3
3
  "description": "DPoP browser client for @web-ts-toolkit/express-oidc-vault with body+cookie transports",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/oidc-vault-dpop-client",
5
- "version": "0.53.0",
5
+ "version": "0.55.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "oidc",