@web-ts-toolkit/oidc-vault-dpop-client 0.51.0 → 0.53.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.
Files changed (7) hide show
  1. package/README.md +483 -73
  2. package/index.d.mts +182 -81
  3. package/index.d.ts +182 -81
  4. package/index.js +943 -226
  5. package/index.mjs +946 -229
  6. package/llms.txt +52 -37
  7. package/package.json +2 -2
package/index.js CHANGED
@@ -45,21 +45,54 @@ var OidcVaultDpopClientError = class extends Error {
45
45
  };
46
46
  var keyLost = () => new OidcVaultDpopClientError("DPOP_KEY_LOST", "The browser login key is missing or changed. Sign in again.", true);
47
47
  var loginRequired = () => new OidcVaultDpopClientError("LOGIN_REQUIRED", "Sign in to continue.", true);
48
- var SERVER_MESSAGES = Object.freeze({
48
+ var vaultRequestTimeout = () => new OidcVaultDpopClientError("DPOP_REQUEST_TIMEOUT", "The vault request timed out.");
49
+ var vaultResponseTooLarge = () => new OidcVaultDpopClientError("DPOP_RESPONSE_TOO_LARGE", "The vault response exceeded the size limit.");
50
+ var TERMINAL_SERVER_MESSAGES = Object.freeze({
49
51
  OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED: "Browser recognition changed; sign in again.",
52
+ OIDC_VAULT_DEVICE_BINDING_REQUIRED: "A device-bound login is required.",
53
+ OIDC_VAULT_INVALID_BROWSER_BINDING: "Login browser binding validation failed.",
54
+ OIDC_VAULT_INVALID_SESSION: "Session is missing or expired.",
55
+ OIDC_VAULT_MISSING_SESSION_ID: "Session handle is missing.",
56
+ OIDC_VAULT_MALFORMED_SESSION_COOKIE: "Session cookie is malformed.",
57
+ OIDC_VAULT_INVALID_EXCHANGE_CODE: "Exchange code is invalid or expired."
58
+ });
59
+ var NON_TERMINAL_SERVER_MESSAGES = Object.freeze({
50
60
  OIDC_VAULT_INVALID_FINGERPRINT: "Fingerprint signal is invalid.",
51
61
  OIDC_VAULT_USE_DPOP_NONCE: "A fresh DPoP nonce is required.",
52
62
  OIDC_VAULT_INVALID_DPOP_PROOF: "DPoP proof validation failed.",
53
63
  OIDC_VAULT_DPOP_REQUIRED: "DPoP authentication is required.",
54
- OIDC_VAULT_DEVICE_BINDING_REQUIRED: "A device-bound login is required.",
55
- OIDC_VAULT_INVALID_BROWSER_BINDING: "Login browser binding validation failed.",
56
- OIDC_VAULT_INVALID_SESSION: "Session is missing or expired.",
57
- OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE: "DPoP replay protection is unavailable."
64
+ OIDC_VAULT_DPOP_REPLAY_UNAVAILABLE: "DPoP replay protection is unavailable.",
65
+ OIDC_VAULT_UNTRUSTED_ORIGIN: "The request origin is not trusted.",
66
+ OIDC_VAULT_INVALID_RETURN_TO: "The login return target is invalid.",
67
+ OIDC_VAULT_MISSING_EXCHANGE_CODE: "The exchange request is missing a code.",
68
+ OIDC_VAULT_REQUEST_BODY_TOO_LARGE: "The vault request body exceeds the size limit.",
69
+ OIDC_VAULT_REQUEST_BODY_PARAMETER_LIMIT_EXCEEDED: "The vault request has too many parameters.",
70
+ OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_ENCODING: "The vault request body encoding is unsupported.",
71
+ OIDC_VAULT_UNSUPPORTED_REQUEST_BODY_TYPE: "The vault request body type is unsupported.",
72
+ OIDC_VAULT_MALFORMED_REQUEST_BODY: "The vault request body is malformed.",
73
+ OIDC_VAULT_INVALID_REQUEST_BODY: "The vault request body is invalid.",
74
+ OIDC_VAULT_INTERNAL_ERROR: "The OIDC request failed.",
75
+ OIDC_VAULT_INVALID_CONFIG: "The OIDC configuration is invalid.",
76
+ OIDC_VAULT_MISSING_CLIENT_ID: "The OIDC client configuration is incomplete.",
77
+ OIDC_VAULT_MISSING_JWKS_URI: "The OIDC signing-key configuration is incomplete.",
78
+ OIDC_VAULT_DISCOVERY_FAILED: "OIDC discovery failed.",
79
+ OIDC_VAULT_DISCOVERY_INVALID: "OIDC discovery metadata is invalid.",
80
+ OIDC_VAULT_JWKS_FAILED: "OIDC signing keys are unavailable.",
81
+ OIDC_VAULT_TOKEN_REQUEST_FAILED: "OIDC token request failed.",
82
+ OIDC_VAULT_INVALID_TOKEN_RESPONSE: "OIDC token response is invalid.",
83
+ OIDC_VAULT_INVALID_ID_TOKEN: "OIDC ID token validation failed.",
84
+ OIDC_VAULT_USERINFO_FAILED: "OIDC userinfo request failed.",
85
+ OIDC_VAULT_INVALID_USERINFO: "OIDC userinfo response is invalid."
58
86
  });
59
87
  var serverError = (status, value) => {
60
- const code = typeof value === "object" && value !== null && "code" in value && typeof value.code === "string" ? value.code : "OIDC_REQUEST_FAILED";
61
- const requiresLogin = status === 401 || code === "OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED" || code === "OIDC_VAULT_INVALID_BROWSER_BINDING";
62
- return new OidcVaultDpopClientError(code, SERVER_MESSAGES[code] ?? "The OIDC request failed.", requiresLogin, status);
88
+ const code = typeof value === "object" && value !== null && !Array.isArray(value) ? Object.getOwnPropertyDescriptor(value, "code")?.value : void 0;
89
+ if (typeof code === "string") {
90
+ if (Object.hasOwn(TERMINAL_SERVER_MESSAGES, code))
91
+ return new OidcVaultDpopClientError(code, TERMINAL_SERVER_MESSAGES[code], true, status);
92
+ if (Object.hasOwn(NON_TERMINAL_SERVER_MESSAGES, code))
93
+ return new OidcVaultDpopClientError(code, NON_TERMINAL_SERVER_MESSAGES[code], false, status);
94
+ }
95
+ return new OidcVaultDpopClientError("OIDC_REQUEST_FAILED", "The OIDC request failed.", false, status);
63
96
  };
64
97
 
65
98
  // src/key-database.ts
@@ -90,9 +123,93 @@ var withDpopDatabase = async (operation) => {
90
123
  }
91
124
  };
92
125
  var readCookieSessionVersion = (scopeId) => withDpopDatabase((db) => db.get("cookieSessions", scopeId));
93
- var writeCookieSessionVersion = (scopeId, value) => withDpopDatabase(async (db) => {
94
- await db.put("cookieSessions", value, scopeId);
95
- });
126
+ var guardDpopTransaction = async (transaction, authority, run) => {
127
+ let aborted = false;
128
+ const abort = () => {
129
+ if (aborted) return;
130
+ aborted = true;
131
+ try {
132
+ transaction.abort();
133
+ } catch {
134
+ }
135
+ };
136
+ authority?.signal.addEventListener("abort", abort, { once: true });
137
+ try {
138
+ const [result] = await Promise.all([
139
+ Promise.resolve().then(() => {
140
+ authority?.assertCurrent();
141
+ return run();
142
+ }),
143
+ transaction.done
144
+ ]);
145
+ authority?.assertCurrent();
146
+ return result;
147
+ } catch (error) {
148
+ abort();
149
+ throw error;
150
+ } finally {
151
+ authority?.signal.removeEventListener("abort", abort);
152
+ }
153
+ };
154
+ var writeCookieSessionVersion = async (scopeId, value, authority) => {
155
+ authority.assertCurrent();
156
+ await withDpopDatabase(async (db) => {
157
+ authority.assertCurrent();
158
+ const transaction = db.transaction("cookieSessions", "readwrite");
159
+ await guardDpopTransaction(transaction, authority, () => transaction.store.put(value, scopeId));
160
+ });
161
+ };
162
+ var scopeState = async (transaction, scopeId) => {
163
+ const keys = transaction.objectStore("keys");
164
+ const cookies = transaction.objectStore("cookieSessions");
165
+ const [keyId, key, cookieId, cookie] = await Promise.all([
166
+ Promise.resolve().then(() => keys.getKey(scopeId)),
167
+ Promise.resolve().then(() => keys.get(scopeId)),
168
+ Promise.resolve().then(() => cookies.getKey(scopeId)),
169
+ Promise.resolve().then(() => cookies.get(scopeId))
170
+ ]);
171
+ return {
172
+ keyPresent: keyId !== void 0,
173
+ keyJkt: typeof key?.jkt === "string" ? key.jkt : void 0,
174
+ cookiePresent: cookieId !== void 0,
175
+ version: cookie && typeof cookie.jkt === "string" && typeof cookie.generation === "string" && typeof cookie.active === "boolean" ? { jkt: cookie.jkt, generation: cookie.generation, active: cookie.active } : void 0
176
+ };
177
+ };
178
+ var readDpopScopeState = async (scopeId, authority) => {
179
+ authority.assertCurrent();
180
+ return withDpopDatabase(async (db) => {
181
+ authority.assertCurrent();
182
+ const transaction = db.transaction(["keys", "cookieSessions"], "readonly");
183
+ return guardDpopTransaction(transaction, authority, () => scopeState(transaction, scopeId));
184
+ });
185
+ };
186
+ var removeDpopScope = async (scopeId, authority) => {
187
+ authority.assertCurrent();
188
+ try {
189
+ return await withDpopDatabase(async (db) => {
190
+ authority.assertCurrent();
191
+ const transaction = db.transaction(["keys", "cookieSessions"], "readwrite");
192
+ return guardDpopTransaction(transaction, authority, async () => {
193
+ const previous = await scopeState(transaction, scopeId);
194
+ authority.assertCurrent();
195
+ const remove = async (store) => {
196
+ authority.assertCurrent();
197
+ await transaction.objectStore(store).delete(scopeId);
198
+ };
199
+ await Promise.all([remove("keys"), remove("cookieSessions")]);
200
+ return previous;
201
+ });
202
+ });
203
+ } catch (error) {
204
+ authority.assertCurrent();
205
+ if (error instanceof OidcVaultDpopClientError) throw error;
206
+ throw new OidcVaultDpopClientError(
207
+ "DPOP_KEY_STORAGE_UNAVAILABLE",
208
+ "IndexedDB key persistence is unavailable.",
209
+ true
210
+ );
211
+ }
212
+ };
96
213
 
97
214
  // src/scope.ts
98
215
  var invalidTarget = () => new OidcVaultDpopClientError("INVALID_DPOP_TARGET", "A pinned absolute HTTP(S) target is required.");
@@ -125,12 +242,21 @@ var normalizeDpopTarget = (value) => {
125
242
  var normalizeStaticOrigin = (value) => {
126
243
  const target = normalizeDpopTarget(value);
127
244
  const url = new URL(value);
128
- if (url.pathname !== "/" || url.search || url.hash || target !== `${url.origin}/`) throw invalidTarget();
245
+ if (!/^https?:\/\/[^/\\?#]+\/?$/i.test(value) || url.pathname !== "/" || url.search || url.hash || target !== `${url.origin}/`) {
246
+ throw invalidTarget();
247
+ }
129
248
  if (url.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
130
249
  throw new OidcVaultDpopClientError("INSECURE_DPOP_ORIGIN", "DPoP requires HTTPS or loopback development.");
131
250
  }
132
251
  return url.origin;
133
252
  };
253
+ var normalizeDpopPathPrefix = (origin, value) => {
254
+ if (value === void 0) return "/";
255
+ if (typeof value !== "string" || value.startsWith("//") || /[?#]/.test(value)) throw invalidTarget();
256
+ const path = normalizePath(origin, value);
257
+ if (path.startsWith("//")) throw invalidTarget();
258
+ return path.replace(/\/+$/g, "") || "/";
259
+ };
134
260
  var resolveDpopScope = (scope) => {
135
261
  const frontendOrigin = normalizeStaticOrigin(scope.frontendOrigin);
136
262
  const backendOrigin = normalizeStaticOrigin(scope.backendOrigin);
@@ -171,15 +297,26 @@ var validateKey = async (value, scopeId) => {
171
297
  throw keyLost();
172
298
  return Object.freeze({ privateKey: value.privateKey, publicJwk: Object.freeze({ ...jwk }), jkt: value.jkt, scopeId });
173
299
  };
174
- var getOrCreateDpopKey = async (input, options = {}) => {
300
+ var readOrCreateKey = async (input, create, authority) => {
301
+ authority?.assertCurrent();
175
302
  assertDpopBrowserFeatures();
176
303
  const { id } = resolveDpopScope(input);
177
304
  try {
178
- const existing = await withDpopDatabase((db) => db.get("keys", id));
179
- if (existing) return await validateKey(existing, id);
180
- if (options.create === false) throw keyLost();
305
+ const existing = await withDpopDatabase((db) => {
306
+ authority?.assertCurrent();
307
+ return db.get("keys", id);
308
+ });
309
+ authority?.assertCurrent();
310
+ if (existing) {
311
+ const key2 = await validateKey(existing, id);
312
+ authority?.assertCurrent();
313
+ return key2;
314
+ }
315
+ if (!create) throw keyLost();
181
316
  const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
317
+ authority?.assertCurrent();
182
318
  const exported = await crypto.subtle.exportKey("jwk", pair.publicKey);
319
+ authority?.assertCurrent();
183
320
  if (!exported.x || !exported.y) throw keyLost();
184
321
  const publicJwk = { kty: "EC", crv: "P-256", x: exported.x, y: exported.y };
185
322
  const candidate = {
@@ -189,15 +326,22 @@ var getOrCreateDpopKey = async (input, options = {}) => {
189
326
  publicJwk,
190
327
  jkt: await (0, import_jose.calculateJwkThumbprint)(publicJwk)
191
328
  };
329
+ authority?.assertCurrent();
192
330
  const winner = await withDpopDatabase(async (db) => {
331
+ authority?.assertCurrent();
193
332
  const transaction = db.transaction("keys", "readwrite");
194
- const stored = await transaction.store.get(id);
195
- if (!stored) await transaction.store.add(candidate, id);
196
- await transaction.done;
197
- return stored ?? candidate;
333
+ return guardDpopTransaction(transaction, authority, async () => {
334
+ const stored = await transaction.store.get(id);
335
+ authority?.assertCurrent();
336
+ if (!stored) await transaction.store.add(candidate, id);
337
+ return stored ?? candidate;
338
+ });
198
339
  });
199
- return await validateKey(winner, id);
340
+ const key = await validateKey(winner, id);
341
+ authority?.assertCurrent();
342
+ return key;
200
343
  } catch (error) {
344
+ authority?.assertCurrent();
201
345
  if (error instanceof OidcVaultDpopClientError) throw error;
202
346
  throw new OidcVaultDpopClientError(
203
347
  "DPOP_KEY_STORAGE_UNAVAILABLE",
@@ -206,11 +350,56 @@ var getOrCreateDpopKey = async (input, options = {}) => {
206
350
  );
207
351
  }
208
352
  };
353
+ var getOrCreateLoginDpopKey = (input, authority) => readOrCreateKey(input, true, authority);
354
+ var getOrCreateDpopKey = (input, options = {}) => readOrCreateKey(input, options.create !== false);
209
355
 
210
356
  // src/dpop-proof.ts
211
357
  var import_jose2 = require("jose");
358
+
359
+ // src/abort.ts
360
+ var bestEffort = (cleanup) => {
361
+ try {
362
+ void Promise.resolve(cleanup()).catch(() => {
363
+ });
364
+ } catch {
365
+ }
366
+ };
367
+ var cancelResponse = (response) => bestEffort(() => response.body?.cancel());
368
+ var assertNotAborted = (signal) => {
369
+ if (signal?.aborted) throw signal.reason;
370
+ };
371
+ var abortable = (signal, start, discard) => new Promise((resolve, reject) => {
372
+ const detach = () => signal?.removeEventListener("abort", onAbort);
373
+ const onAbort = () => {
374
+ detach();
375
+ reject(signal.reason);
376
+ };
377
+ if (signal?.aborted) {
378
+ reject(signal.reason);
379
+ return;
380
+ }
381
+ signal?.addEventListener("abort", onAbort, { once: true });
382
+ const failed = (error) => {
383
+ detach();
384
+ reject(signal?.aborted ? signal.reason : error);
385
+ };
386
+ try {
387
+ void Promise.resolve(start()).then((value) => {
388
+ detach();
389
+ if (signal?.aborted) {
390
+ if (discard) bestEffort(() => discard(value));
391
+ reject(signal.reason);
392
+ } else resolve(value);
393
+ }, failed);
394
+ } catch (error) {
395
+ failed(error);
396
+ }
397
+ });
398
+
399
+ // src/dpop-proof.ts
212
400
  var createDpopProof = async (key, input) => {
213
- const { method, url, accessToken, nonce, now = Date.now } = input;
401
+ const { method, url, accessToken, nonce, now = Date.now, signal } = input;
402
+ assertNotAborted(signal);
214
403
  if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(method) || method !== method.toUpperCase()) {
215
404
  throw new OidcVaultDpopClientError("INVALID_DPOP_METHOD", "Use the exact uppercase request method.");
216
405
  }
@@ -225,18 +414,24 @@ var createDpopProof = async (key, input) => {
225
414
  if (!/^[\x21-\x7e]+$/.test(accessToken))
226
415
  throw new OidcVaultDpopClientError("INVALID_DPOP_TOKEN", "The access token is invalid.");
227
416
  ath = import_jose2.base64url.encode(
228
- new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)))
417
+ new Uint8Array(
418
+ await abortable(signal, () => crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)))
419
+ )
229
420
  );
230
421
  }
422
+ assertNotAborted(signal);
231
423
  const jti = import_jose2.base64url.encode(crypto.getRandomValues(new Uint8Array(16)));
232
- return new import_jose2.SignJWT({
233
- htm: method,
234
- htu: normalizeDpopTarget(url),
235
- iat,
236
- jti,
237
- ...ath === void 0 ? {} : { ath },
238
- ...nonce === void 0 ? {} : { nonce }
239
- }).setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: { ...key.publicJwk } }).sign(key.privateKey);
424
+ return abortable(
425
+ signal,
426
+ () => new import_jose2.SignJWT({
427
+ htm: method,
428
+ htu: normalizeDpopTarget(url),
429
+ iat,
430
+ jti,
431
+ ...ath === void 0 ? {} : { ath },
432
+ ...nonce === void 0 ? {} : { nonce }
433
+ }).setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: { ...key.publicJwk } }).sign(key.privateKey)
434
+ );
240
435
  };
241
436
 
242
437
  // src/auth-session.ts
@@ -249,59 +444,73 @@ var invalidCredentials = () => new OidcVaultDpopClientError(
249
444
  "A matching DPoP JWT response is required. Sign in again.",
250
445
  true
251
446
  );
252
- var parseDpopCredentials = (value, transport, jkt, now) => {
253
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidCredentials();
254
- const { accessToken, tokenType, expiresIn, sessionId, user } = value;
255
- if (typeof accessToken !== "string" || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(accessToken) || tokenType !== "DPoP" || typeof expiresIn !== "number" || !Number.isSafeInteger(expiresIn) || expiresIn < 0 || transport === "body" && (typeof sessionId !== "string" || sessionId.length === 0) || transport === "cookie" && "sessionId" in value)
256
- throw invalidCredentials();
257
- let expiration;
447
+ var isSafeNonnegativeInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
448
+ var decodeBoundJwtExpiration = (accessToken, jkt) => {
258
449
  try {
450
+ const parts = accessToken.split(".");
451
+ if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part) && import_jose3.base64url.encode(import_jose3.base64url.decode(part)) === part))
452
+ throw invalidCredentials();
259
453
  const header = (0, import_jose3.decodeProtectedHeader)(accessToken);
260
454
  const claims = (0, import_jose3.decodeJwt)(accessToken);
261
455
  const cnf = claims.cnf;
262
- if (!header.alg || header.alg === "none" || header.b64 === false || typeof cnf !== "object" || cnf === null || Array.isArray(cnf) || !("jkt" in cnf) || cnf.jkt !== jkt || typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || claims.exp < 0)
456
+ if (typeof header.alg !== "string" || header.alg.length === 0 || header.alg === "none" || header.b64 === false || typeof jkt !== "string" || jkt.length !== 43 || !/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(jkt) || typeof cnf !== "object" || cnf === null || Array.isArray(cnf) || Reflect.ownKeys(cnf).length !== 1 || !Object.hasOwn(cnf, "jkt") || !("jkt" in cnf) || cnf.jkt !== jkt || !isSafeNonnegativeInteger(claims.exp))
263
457
  throw invalidCredentials();
264
- expiration = Math.min(now + expiresIn * 1e3, claims.exp * 1e3);
265
- if (!Number.isSafeInteger(expiration)) throw invalidCredentials();
458
+ const expiration = claims.exp * 1e3;
459
+ if (!isSafeNonnegativeInteger(expiration)) throw invalidCredentials();
460
+ return expiration;
266
461
  } catch {
267
462
  throw invalidCredentials();
268
463
  }
464
+ };
465
+ var createToken = (accessToken, expiresAt, jkt, generation, user) => {
269
466
  if (user !== void 0 && (typeof user !== "object" || user === null || Array.isArray(user) || !("sub" in user) || typeof user.sub !== "string"))
270
467
  throw invalidCredentials();
271
- const token = Object.freeze({
468
+ return Object.freeze({
272
469
  accessToken,
273
- tokenType,
274
- expiresAt: expiration,
470
+ tokenType: "DPoP",
471
+ expiresAt,
275
472
  jkt,
276
- generation: crypto.randomUUID(),
473
+ generation,
277
474
  ...user === void 0 ? {} : { user: structuredClone(user) }
278
475
  });
476
+ };
477
+ var parseDpopCredentials = (value, transport, jkt, now) => {
478
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidCredentials();
479
+ const { accessToken, tokenType, expiresIn, sessionId, user } = value;
480
+ if (typeof accessToken !== "string" || tokenType !== "DPoP" || !isSafeNonnegativeInteger(expiresIn) || !isSafeNonnegativeInteger(now) || transport === "body" && (typeof sessionId !== "string" || sessionId.length === 0) || transport === "cookie" && "sessionId" in value)
481
+ throw invalidCredentials();
482
+ const lifetimeMs = expiresIn * 1e3;
483
+ if (!isSafeNonnegativeInteger(lifetimeMs)) throw invalidCredentials();
484
+ const responseDeadline = now + lifetimeMs;
485
+ if (!isSafeNonnegativeInteger(responseDeadline)) throw invalidCredentials();
486
+ const expiration = Math.min(responseDeadline, decodeBoundJwtExpiration(accessToken, jkt));
487
+ const token = createToken(accessToken, expiration, jkt, crypto.randomUUID(), user);
279
488
  return { token, ...transport === "body" ? { sessionId } : {} };
280
489
  };
281
490
  var readPeerToken = (value, jkt, generation, now) => {
282
- if (typeof value !== "object" || value === null) return void 0;
283
- const candidate = value;
284
- if (candidate.jkt !== jkt || candidate.generation !== generation || candidate.tokenType !== "DPoP" || !Number.isSafeInteger(candidate.expiresAt) || candidate.expiresAt <= now)
491
+ if (!isSafeNonnegativeInteger(now) || typeof value !== "object" || value === null || Array.isArray(value) || "sessionId" in value)
285
492
  return void 0;
286
493
  try {
287
- const { token } = parseDpopCredentials(
288
- {
289
- accessToken: candidate.accessToken,
290
- tokenType: candidate.tokenType,
291
- expiresIn: Math.ceil((candidate.expiresAt - now) / 1e3),
292
- user: candidate.user
293
- },
294
- "cookie",
295
- jkt,
296
- now
297
- );
298
- return Object.freeze({ ...token, expiresAt: Math.min(token.expiresAt, candidate.expiresAt), generation });
494
+ const {
495
+ accessToken,
496
+ tokenType,
497
+ expiresAt,
498
+ jkt: peerJkt,
499
+ generation: peerGeneration,
500
+ user
501
+ } = value;
502
+ if (typeof accessToken !== "string" || peerJkt !== jkt || peerGeneration !== generation || tokenType !== "DPoP" || !isSafeNonnegativeInteger(expiresAt) || expiresAt <= now)
503
+ return void 0;
504
+ const expiration = Math.min(expiresAt, decodeBoundJwtExpiration(accessToken, jkt));
505
+ if (expiration <= now) return void 0;
506
+ return createToken(accessToken, expiration, jkt, generation, user);
299
507
  } catch {
300
508
  return void 0;
301
509
  }
302
510
  };
303
511
 
304
512
  // src/nonce-cache.ts
513
+ var MAX_ENTRIES = 64;
305
514
  var DpopNonceCache = class {
306
515
  constructor() {
307
516
  this.nonces = /* @__PURE__ */ new Map();
@@ -309,16 +518,147 @@ var DpopNonceCache = class {
309
518
  get(space, jkt) {
310
519
  return this.nonces.get(JSON.stringify([space, jkt]));
311
520
  }
521
+ /**
522
+ * Accept one 1..512-character visible-ASCII value, excluding whitespace,
523
+ * double quotes, backslashes and commas (including coalesced duplicate header
524
+ * values). Invalid input returns false without overwriting or changing order.
525
+ */
312
526
  remember(space, jkt, nonce) {
313
- if (!nonce || nonce.length > 512 || !/^[\x20-\x7e]+$/.test(nonce)) return false;
314
- this.nonces.set(JSON.stringify([space, jkt]), nonce);
527
+ if (typeof nonce !== "string" || nonce.length > 512 || !/^[\x21\x23-\x2b\x2d-\x5b\x5d-\x7e]+$/.test(nonce))
528
+ return false;
529
+ const id = JSON.stringify([space, jkt]);
530
+ this.nonces.delete(id);
531
+ if (this.nonces.size === MAX_ENTRIES) this.nonces.delete(this.nonces.keys().next().value);
532
+ this.nonces.set(id, nonce);
315
533
  return true;
316
534
  }
535
+ /** Forget every tuple in this cache; does not invalidate credentials or server nonce state. */
317
536
  clear() {
318
537
  this.nonces.clear();
319
538
  }
320
539
  };
321
540
 
541
+ // src/session-lifecycle.ts
542
+ var superseded = () => new OidcVaultDpopClientError("DPOP_OPERATION_SUPERSEDED", "This browser auth operation was superseded.");
543
+ var closed = () => new OidcVaultDpopClientError("DPOP_SESSION_DISPOSED", "This browser auth context is closed.");
544
+ var createSessionLifecycle = () => {
545
+ let disposed = false;
546
+ let controller = new AbortController();
547
+ let tail = Promise.resolve();
548
+ const assertLive = () => {
549
+ if (disposed) throw closed();
550
+ };
551
+ const capture = (current = () => true) => {
552
+ assertLive();
553
+ const { signal } = controller;
554
+ return {
555
+ signal,
556
+ isCurrent: () => !disposed && !signal.aborted && current(),
557
+ assertCurrent() {
558
+ assertLive();
559
+ if (signal.aborted || !current()) throw superseded();
560
+ }
561
+ };
562
+ };
563
+ return {
564
+ capture,
565
+ isLive: () => !disposed,
566
+ invalidate() {
567
+ assertLive();
568
+ const previous = controller;
569
+ controller = new AbortController();
570
+ previous.abort(superseded());
571
+ return capture();
572
+ },
573
+ enqueue(authority, run) {
574
+ const pending = tail.then(() => {
575
+ authority.assertCurrent();
576
+ return run();
577
+ });
578
+ tail = pending.then(
579
+ () => {
580
+ },
581
+ () => {
582
+ }
583
+ );
584
+ return pending;
585
+ },
586
+ dispose() {
587
+ if (disposed) return;
588
+ disposed = true;
589
+ controller.abort(closed());
590
+ }
591
+ };
592
+ };
593
+
594
+ // src/vault-http.ts
595
+ var requestVaultJson = async (request, url, init, { signal, requestTimeoutMs, maxResponseBytes }) => {
596
+ assertNotAborted(signal);
597
+ const controller = new AbortController();
598
+ const onAbort = () => controller.abort(signal.reason);
599
+ signal.addEventListener("abort", onAbort, { once: true });
600
+ const started = performance.now();
601
+ const timeout = setTimeout(() => controller.abort(vaultRequestTimeout()), requestTimeoutMs);
602
+ const assertActive = () => {
603
+ if (!controller.signal.aborted && performance.now() - started >= requestTimeoutMs)
604
+ controller.abort(vaultRequestTimeout());
605
+ assertNotAborted(controller.signal);
606
+ };
607
+ let response;
608
+ let reader;
609
+ let bodyDone = false;
610
+ try {
611
+ response = await abortable(
612
+ controller.signal,
613
+ () => request(url, { ...init, signal: controller.signal }),
614
+ cancelResponse
615
+ );
616
+ assertActive();
617
+ const status = response.status;
618
+ const nonce = response.headers.get("DPoP-Nonce");
619
+ let value;
620
+ try {
621
+ if (response.body) {
622
+ const bodyReader = response.body.getReader();
623
+ reader = bodyReader;
624
+ const decoder = new TextDecoder();
625
+ let bytes = 0;
626
+ let text = "";
627
+ for (; ; ) {
628
+ assertActive();
629
+ const chunk = await abortable(controller.signal, () => bodyReader.read());
630
+ assertActive();
631
+ if (chunk.done) {
632
+ bodyDone = true;
633
+ break;
634
+ }
635
+ if (!ArrayBuffer.isView(chunk.value)) throw new TypeError("Invalid vault response body.");
636
+ if (chunk.value.byteLength > maxResponseBytes - bytes) {
637
+ controller.abort(vaultResponseTooLarge());
638
+ assertActive();
639
+ }
640
+ bytes += chunk.value.byteLength;
641
+ text += decoder.decode(chunk.value, { stream: true });
642
+ }
643
+ value = JSON.parse(text + decoder.decode());
644
+ }
645
+ } catch {
646
+ assertActive();
647
+ value = void 0;
648
+ }
649
+ assertActive();
650
+ return { status, nonce, value };
651
+ } finally {
652
+ clearTimeout(timeout);
653
+ signal.removeEventListener("abort", onAbort);
654
+ if (reader) {
655
+ const bodyReader = reader;
656
+ if (!bodyDone) bestEffort(() => bodyReader.cancel());
657
+ bestEffort(() => bodyReader.releaseLock());
658
+ } else if (response) cancelResponse(response);
659
+ }
660
+ };
661
+
322
662
  // src/auth-session.ts
323
663
  var createOidcVaultDpopSession = (options) => {
324
664
  const {
@@ -326,11 +666,17 @@ var createOidcVaultDpopSession = (options) => {
326
666
  basePath = "/auth/oidc",
327
667
  sessionTransport = "body",
328
668
  fingerprint,
669
+ requestTimeoutMs = 15e3,
670
+ maxResponseBytes = 1048576,
329
671
  fetch: request = globalThis.fetch.bind(globalThis),
330
672
  navigate = (url) => location.assign(url),
331
673
  now = Date.now
332
674
  } = options;
333
675
  if (sessionTransport !== "body" && sessionTransport !== "cookie") throw new TypeError("Unknown session transport.");
676
+ if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1 || requestTimeoutMs > 2147483647)
677
+ throw new TypeError("requestTimeoutMs must be an integer from 1 to 2147483647 milliseconds.");
678
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1)
679
+ throw new TypeError("maxResponseBytes must be a positive safe integer.");
334
680
  assertDpopBrowserFeatures(sessionTransport === "cookie");
335
681
  const scope = resolveDpopScope({ frontendOrigin: location.origin, backendOrigin, basePath });
336
682
  const storageKey = `oidc-vault-dpop:handle:${scope.id}`;
@@ -341,9 +687,13 @@ var createOidcVaultDpopSession = (options) => {
341
687
  const channel = sessionTransport === "cookie" ? new BroadcastChannel(lockName) : void 0;
342
688
  let token;
343
689
  let tokenRecognition;
690
+ const cookieBindings = /* @__PURE__ */ new WeakMap();
344
691
  let refreshPromise;
345
- let disposed = false;
346
- let epoch = 0;
692
+ let resetPromise;
693
+ const lifecycle = createSessionLifecycle();
694
+ let publication = 0;
695
+ let loginIntent = 0;
696
+ let acceptPeerTokens = true;
347
697
  const peerWaiters = /* @__PURE__ */ new Set();
348
698
  const readStorage = (key) => {
349
699
  try {
@@ -373,35 +723,65 @@ var createOidcVaultDpopSession = (options) => {
373
723
  const clearLocal = (clearPendingLogin = true) => {
374
724
  token = void 0;
375
725
  tokenRecognition = void 0;
376
- epoch++;
726
+ acceptPeerTokens = false;
727
+ publication++;
728
+ for (const waiter of peerWaiters) waiter.finish();
377
729
  nonces.clear();
378
730
  store(storageKey);
379
731
  if (clearPendingLogin) store(pendingKey);
380
732
  };
381
- const assertLive = () => {
382
- if (disposed) throw new OidcVaultDpopClientError("DPOP_SESSION_DISPOSED", "This browser auth context is closed.");
733
+ const invalidateLocal = (clearPendingLogin = true) => {
734
+ const authority = lifecycle.invalidate();
735
+ refreshPromise = void 0;
736
+ resetPromise = void 0;
737
+ clearLocal(clearPendingLogin);
738
+ return authority;
383
739
  };
384
740
  const withCookieLock = (operation2) => sessionTransport === "cookie" ? navigator.locks.request(lockName, { mode: "exclusive" }, operation2) : operation2();
385
- const invalidate = async () => {
386
- const oldJkt = token?.jkt;
387
- clearLocal();
741
+ const invalidateCookie = async (authority, oldJkt, expected) => {
742
+ authority.assertCurrent();
388
743
  if (channel) {
389
- const previous = await readCookieSessionVersion(scope.id);
744
+ let previous;
745
+ if (expected !== void 0) {
746
+ const current = await readDpopScopeState(scope.id, authority);
747
+ authority.assertCurrent();
748
+ if (!expected || !current.keyPresent && !current.cookiePresent || current.keyJkt !== void 0 && current.keyJkt !== expected.jkt || current.version && (expected.generation !== void 0 && current.version.generation !== expected.generation || current.version.active && current.version.jkt !== expected.jkt))
749
+ return;
750
+ previous = current.version;
751
+ } else previous = await readCookieSessionVersion(scope.id);
752
+ authority.assertCurrent();
390
753
  const jkt = oldJkt ?? previous?.jkt ?? "";
391
754
  const generation = crypto.randomUUID();
392
- await writeCookieSessionVersion(scope.id, { jkt, generation, active: false });
755
+ const next = { jkt, generation, active: false };
756
+ await writeCookieSessionVersion(scope.id, next, authority);
757
+ authority.assertCurrent();
758
+ cookieBindings.set(authority, next);
393
759
  channel.postMessage({ type: "clear", sender, jkt, generation });
394
760
  }
395
761
  };
396
- const existingKey = async (expectedJkt) => {
397
- assertLive();
762
+ const existingKey = async (authority, expectedJkt) => {
763
+ authority.assertCurrent();
764
+ if (channel && expectedJkt !== void 0)
765
+ cookieBindings.set(authority, {
766
+ ...cookieBindings.get(authority),
767
+ jkt: expectedJkt
768
+ });
398
769
  const key = await getOrCreateDpopKey(scope, { create: false });
770
+ authority.assertCurrent();
399
771
  if (expectedJkt !== void 0 && expectedJkt !== key.jkt) throw keyLost();
400
772
  return key;
401
773
  };
402
- const authenticatedKey = async () => {
403
- const expected = token?.jkt ?? (sessionTransport === "body" ? readStorage(storageKey)?.jkt : (await readCookieSessionVersion(scope.id))?.jkt);
404
- const key = await existingKey(expected);
774
+ const authenticatedKey = async (authority) => {
775
+ authority.assertCurrent();
776
+ let version;
777
+ if (!token && channel) {
778
+ version = await readCookieSessionVersion(scope.id);
779
+ authority.assertCurrent();
780
+ if (version) cookieBindings.set(authority, { ...version });
781
+ }
782
+ const expected = token?.jkt ?? (sessionTransport === "body" ? readStorage(storageKey)?.jkt : version?.jkt);
783
+ const key = await existingKey(authority, expected);
784
+ authority.assertCurrent();
405
785
  if (!expected) throw loginRequired();
406
786
  return key;
407
787
  };
@@ -417,49 +797,83 @@ var createOidcVaultDpopSession = (options) => {
417
797
  )
418
798
  )
419
799
  );
420
- const currentRecognition = async () => recognitionMarker(await fingerprint?.headers() ?? {});
421
- const post = async (route, body, key) => {
800
+ const currentRecognition = async (authority) => {
801
+ authority.assertCurrent();
802
+ const headers = await fingerprint?.headers() ?? {};
803
+ authority.assertCurrent();
804
+ const marker = await recognitionMarker(headers);
805
+ authority.assertCurrent();
806
+ return marker;
807
+ };
808
+ const post = async (authority, route, body, key) => {
422
809
  const url = `${scope.backendOrigin}${scope.basePath === "/" ? "" : scope.basePath}/${route}`;
423
- for (let attempt = 0; attempt < 2; attempt++) {
810
+ const { jkt } = key;
811
+ let nonceRetried = false;
812
+ for (; ; ) {
813
+ authority.assertCurrent();
424
814
  const recognition = route === "logout" ? {} : await fingerprint?.headers() ?? {};
425
- const currentKey = await existingKey(key.jkt);
815
+ const currentKey = await existingKey(authority, jkt);
816
+ authority.assertCurrent();
426
817
  const proof = await createDpopProof(currentKey, {
427
818
  method: "POST",
428
819
  url,
429
820
  now,
430
- nonce: nonces.get(scope.id, key.jkt)
431
- });
432
- const response = await request(url, {
433
- method: "POST",
434
- credentials: route === "login" || route === "exchange" || sessionTransport === "cookie" ? "include" : "omit",
435
- redirect: "error",
436
- cache: "no-store",
437
- headers: { "Content-Type": "application/json", ...recognition, DPoP: proof },
438
- body: JSON.stringify(body)
821
+ nonce: nonces.get(scope.id, jkt),
822
+ signal: authority.signal
439
823
  });
440
- let value;
441
- try {
442
- value = await response.json();
443
- } catch {
444
- value = void 0;
824
+ authority.assertCurrent();
825
+ const { status, nonce, value } = await requestVaultJson(
826
+ request,
827
+ url,
828
+ {
829
+ method: "POST",
830
+ credentials: route === "login" || route === "exchange" || sessionTransport === "cookie" ? "include" : "omit",
831
+ redirect: "error",
832
+ cache: "no-store",
833
+ headers: { "Content-Type": "application/json", ...recognition, DPoP: proof },
834
+ body: JSON.stringify(body)
835
+ },
836
+ { signal: authority.signal, requestTimeoutMs, maxResponseBytes }
837
+ );
838
+ authority.assertCurrent();
839
+ const learnedNonce = nonces.remember(scope.id, jkt, nonce);
840
+ if (status >= 200 && status < 300) {
841
+ const marker = await recognitionMarker(recognition);
842
+ authority.assertCurrent();
843
+ return { value, recognition: marker };
844
+ }
845
+ const error = serverError(status, value);
846
+ if (error.code === "OIDC_VAULT_USE_DPOP_NONCE" && (status === 400 || status === 401)) {
847
+ if (nonceRetried)
848
+ throw new OidcVaultDpopClientError(
849
+ "DPOP_NONCE_RETRY_EXHAUSTED",
850
+ "The DPoP nonce challenge was repeated.",
851
+ false,
852
+ status
853
+ );
854
+ if (learnedNonce) {
855
+ nonceRetried = true;
856
+ continue;
857
+ }
445
858
  }
446
- if (response.ok) return { value, recognition: await recognitionMarker(recognition) };
447
- const error = serverError(response.status, value);
448
- if (error.code === "OIDC_VAULT_USE_DPOP_NONCE" && (response.status === 400 || response.status === 401) && attempt === 0 && nonces.remember(scope.id, key.jkt, response.headers.get("DPoP-Nonce")))
449
- continue;
450
859
  throw error;
451
860
  }
452
- throw new OidcVaultDpopClientError("DPOP_NONCE_RETRY_EXHAUSTED", "The DPoP nonce challenge was repeated.");
453
861
  };
454
- const publish = async (next, recognition, operationEpoch) => {
455
- await existingKey(next.jkt);
456
- if (epoch !== operationEpoch || disposed) throw loginRequired();
862
+ const publish = async (next, recognition, authority, sessionId) => {
863
+ await existingKey(authority, next.jkt);
864
+ authority.assertCurrent();
457
865
  if (channel) {
458
- await writeCookieSessionVersion(scope.id, { jkt: next.jkt, generation: next.generation, active: true });
866
+ const version = { jkt: next.jkt, generation: next.generation, active: true };
867
+ await writeCookieSessionVersion(scope.id, version, authority);
868
+ authority.assertCurrent();
869
+ cookieBindings.set(authority, version);
459
870
  }
460
- if (epoch !== operationEpoch || disposed) throw loginRequired();
871
+ authority.assertCurrent();
872
+ if (sessionTransport === "body") store(storageKey, { sessionId, jkt: next.jkt });
461
873
  token = next;
462
874
  tokenRecognition = recognition;
875
+ acceptPeerTokens = true;
876
+ publication++;
463
877
  channel?.postMessage({
464
878
  type: "token",
465
879
  sender,
@@ -470,52 +884,101 @@ var createOidcVaultDpopSession = (options) => {
470
884
  });
471
885
  return next;
472
886
  };
473
- const acceptResponse = async (response, key, operationEpoch) => {
474
- if (epoch !== operationEpoch || disposed) throw loginRequired();
887
+ const acceptResponse = async (response, key, authority) => {
888
+ authority.assertCurrent();
475
889
  const parsed = parseDpopCredentials(response.value, sessionTransport, key.jkt, now());
476
- if (sessionTransport === "body") store(storageKey, { sessionId: parsed.sessionId, jkt: key.jkt });
477
- return publish(parsed.token, response.recognition, operationEpoch);
890
+ return publish(parsed.token, response.recognition, authority, parsed.sessionId);
891
+ };
892
+ const stateAuthority = (current = () => true) => {
893
+ const captured = publication;
894
+ return lifecycle.capture(() => publication === captured && current());
895
+ };
896
+ const peerAuthority = (current = () => true) => {
897
+ const intent = loginIntent;
898
+ return stateAuthority(() => loginIntent === intent && current());
478
899
  };
479
- const adoptPeer = async (message, expectedRecognition) => {
480
- if (disposed || message.type !== "token") return void 0;
481
- const operationEpoch = epoch;
482
- const recognition = expectedRecognition ?? await currentRecognition();
900
+ const onPeerKeyFailure = (error, authority, jkt) => {
901
+ if (!authority.isCurrent() || token?.jkt !== jkt || !(error instanceof OidcVaultDpopClientError) || !error.requiresLogin)
902
+ return;
903
+ try {
904
+ invalidateLocal(false);
905
+ } catch {
906
+ }
907
+ };
908
+ const adoptPeer = async (message, authority, expectedRecognition) => {
909
+ authority.assertCurrent();
910
+ const unsolicited = expectedRecognition === void 0;
911
+ if (message.type !== "token" || !acceptPeerTokens && unsolicited) return void 0;
912
+ const recognition = expectedRecognition ?? await currentRecognition(authority);
913
+ authority.assertCurrent();
483
914
  if (message.recognition !== recognition) return void 0;
484
915
  const current = await readCookieSessionVersion(scope.id);
916
+ authority.assertCurrent();
485
917
  if (!current?.active || current.jkt !== message.jkt || current.generation !== message.generation) return void 0;
486
918
  const peer = readPeerToken(message.token, current.jkt, current.generation, now());
487
919
  if (!peer) return void 0;
488
- await existingKey(current.jkt);
920
+ try {
921
+ await existingKey(authority, current.jkt);
922
+ } catch (error) {
923
+ if (unsolicited) onPeerKeyFailure(error, authority, current.jkt);
924
+ throw error;
925
+ }
926
+ authority.assertCurrent();
489
927
  const latest = await readCookieSessionVersion(scope.id);
490
- if (epoch !== operationEpoch || disposed || !latest?.active || latest.generation !== current.generation)
928
+ authority.assertCurrent();
929
+ if (!latest?.active || latest.jkt !== current.jkt || latest.generation !== current.generation || peer.expiresAt <= now())
491
930
  return void 0;
931
+ if (token?.generation === peer.generation && token.accessToken === peer.accessToken && token.expiresAt === peer.expiresAt && tokenRecognition === recognition)
932
+ return token;
492
933
  token = peer;
493
934
  tokenRecognition = recognition;
935
+ acceptPeerTokens = true;
936
+ publication++;
494
937
  return peer;
495
938
  };
496
- const requestPeer = (jkt, generation, recognition, rejectedToken) => new Promise((resolve) => {
939
+ const requestPeer = (authority, jkt, generation, recognition, rejectedToken) => new Promise((resolve, reject) => {
940
+ authority.assertCurrent();
497
941
  let settled = false;
942
+ let adopting = false;
498
943
  const finish = (value) => {
499
944
  if (settled) return;
500
945
  settled = true;
501
946
  clearTimeout(timeout);
947
+ authority.signal.removeEventListener("abort", onInvalidation);
502
948
  peerWaiters.delete(waiter);
503
949
  resolve(value);
504
950
  };
505
- const waiter = (message) => {
506
- if (message.jkt !== jkt || message.generation !== generation) return;
507
- void adoptPeer(message, recognition).then((peer) => {
508
- if (peer && peer.accessToken !== rejectedToken) finish(peer);
509
- }).catch(() => finish());
951
+ const onInvalidation = () => finish();
952
+ const waiter = {
953
+ finish: () => finish(),
954
+ receive(message) {
955
+ if (settled || adopting || message.jkt !== jkt || message.generation !== generation) return;
956
+ adopting = true;
957
+ void adoptPeer(
958
+ message,
959
+ peerAuthority(() => !settled && authority.isCurrent()),
960
+ recognition
961
+ ).then((peer) => {
962
+ adopting = false;
963
+ if (peer && peer.accessToken !== rejectedToken) finish(peer);
964
+ }).catch(() => finish());
965
+ }
510
966
  };
511
967
  const timeout = setTimeout(() => finish(), 180);
512
968
  peerWaiters.add(waiter);
513
- channel?.postMessage({ type: "request-token", sender, jkt, generation, recognition });
969
+ authority.signal.addEventListener("abort", onInvalidation, { once: true });
970
+ try {
971
+ channel?.postMessage({ type: "request-token", sender, jkt, generation, recognition });
972
+ } catch (error) {
973
+ reject(error);
974
+ finish();
975
+ }
514
976
  });
515
977
  if (channel)
516
978
  channel.onmessage = (event) => {
979
+ if (!lifecycle.isLive()) return;
517
980
  const message = event.data;
518
- if (!message || message.sender === sender || typeof message.sender !== "string" || typeof message.jkt !== "string" || typeof message.generation !== "string" || !["token", "request-token", "clear"].includes(message.type ?? ""))
981
+ if (!message || message.sender === sender || typeof message.sender !== "string" || typeof message.jkt !== "string" || typeof message.generation !== "string" || message.keyJkt !== void 0 && typeof message.keyJkt !== "string" || !["token", "request-token", "clear", "reset-key"].includes(message.type ?? ""))
519
982
  return;
520
983
  const captured = message;
521
984
  if (captured.type === "request-token") {
@@ -530,38 +993,83 @@ var createOidcVaultDpopSession = (options) => {
530
993
  recognition: tokenRecognition
531
994
  });
532
995
  }
533
- } else if (captured.type === "clear") {
534
- void withCookieLock(async () => {
535
- const version = await readCookieSessionVersion(scope.id);
536
- if (!version?.active && version?.generation === captured.generation) clearLocal(false);
537
- }).catch(() => clearLocal(false));
996
+ } else if (captured.type === "clear" || captured.type === "reset-key") {
997
+ const authority = peerAuthority();
998
+ void lifecycle.enqueue(
999
+ authority,
1000
+ () => withCookieLock(async () => {
1001
+ authority.assertCurrent();
1002
+ if (captured.type === "clear") {
1003
+ const version = await readCookieSessionVersion(scope.id);
1004
+ authority.assertCurrent();
1005
+ if (!version?.active && version?.generation === captured.generation && version.jkt === captured.jkt)
1006
+ invalidateLocal(false);
1007
+ return;
1008
+ }
1009
+ const current = await readDpopScopeState(scope.id, authority);
1010
+ authority.assertCurrent();
1011
+ if (current.keyPresent || current.cookiePresent) return;
1012
+ const matchesKey = (jkt) => jkt === captured.jkt || jkt === captured.keyJkt;
1013
+ const pending = readStorage(pendingKey);
1014
+ if (pending && !matchesKey(pending.jkt)) return;
1015
+ if (token && !matchesKey(token.jkt) && token.generation !== captured.generation) return;
1016
+ invalidateLocal();
1017
+ })
1018
+ ).catch(() => {
1019
+ });
538
1020
  } else {
539
- for (const waiter of peerWaiters) waiter(captured);
540
- void adoptPeer(captured).catch(() => clearLocal(false));
1021
+ if (peerWaiters.size) for (const waiter of peerWaiters) waiter.receive(captured);
1022
+ else {
1023
+ const authority = peerAuthority();
1024
+ void adoptPeer(captured, authority).catch(() => {
1025
+ });
1026
+ }
541
1027
  }
542
1028
  };
543
- const onFailure = async (error) => {
544
- if (!disposed && error instanceof OidcVaultDpopClientError && error.requiresLogin) await invalidate();
1029
+ const onFailure = async (error, authority) => {
1030
+ authority.assertCurrent();
1031
+ if (error instanceof OidcVaultDpopClientError && error.requiresLogin) {
1032
+ const oldJkt = token?.jkt;
1033
+ const expected = token ? { jkt: token.jkt, generation: token.generation } : cookieBindings.get(authority) ?? null;
1034
+ const invalidation = invalidateLocal();
1035
+ await invalidateCookie(invalidation, oldJkt, expected);
1036
+ invalidation.assertCurrent();
1037
+ }
545
1038
  throw error;
546
1039
  };
547
- const operation = (run) => withCookieLock(async () => {
548
- try {
549
- return await run();
550
- } catch (error) {
551
- return onFailure(error);
552
- }
553
- });
1040
+ const operation = (authority, run) => lifecycle.enqueue(
1041
+ authority,
1042
+ () => withCookieLock(async () => {
1043
+ authority.assertCurrent();
1044
+ try {
1045
+ const result = await run();
1046
+ authority.assertCurrent();
1047
+ return result;
1048
+ } catch (error) {
1049
+ return onFailure(error, authority);
1050
+ }
1051
+ })
1052
+ );
554
1053
  const session = {
555
1054
  scopeId: scope.id,
556
1055
  sessionTransport,
557
1056
  async login(returnTo) {
558
- assertLive();
559
- await operation(async () => {
560
- await invalidate();
561
- const key = await getOrCreateDpopKey(scope);
1057
+ const authority = lifecycle.capture();
1058
+ loginIntent++;
1059
+ refreshPromise = void 0;
1060
+ resetPromise = void 0;
1061
+ await operation(authority, async () => {
1062
+ const oldJkt = token?.jkt;
1063
+ clearLocal();
1064
+ await invalidateCookie(authority, oldJkt);
1065
+ authority.assertCurrent();
1066
+ const key = await getOrCreateLoginDpopKey(scope, authority);
1067
+ authority.assertCurrent();
562
1068
  store(pendingKey, { jkt: key.jkt });
1069
+ publication++;
563
1070
  const body = returnTo === void 0 ? {} : { returnTo };
564
- const { value } = await post("login", body, key);
1071
+ const { value } = await post(authority, "login", body, key);
1072
+ authority.assertCurrent();
565
1073
  if (typeof value !== "object" || value === null || !("authorizationUrl" in value) || typeof value.authorizationUrl !== "string") {
566
1074
  throw new OidcVaultDpopClientError(
567
1075
  "INVALID_LOGIN_RESPONSE",
@@ -586,56 +1094,69 @@ var createOidcVaultDpopSession = (options) => {
586
1094
  true
587
1095
  );
588
1096
  }
1097
+ authority.assertCurrent();
589
1098
  navigate(url.href);
590
1099
  });
591
1100
  },
592
1101
  async exchange(code) {
593
- assertLive();
594
- return operation(async () => {
1102
+ const authority = lifecycle.capture();
1103
+ refreshPromise = void 0;
1104
+ resetPromise = void 0;
1105
+ return operation(authority, async () => {
595
1106
  const pending = readStorage(pendingKey);
596
- const key = await existingKey(pending?.jkt);
1107
+ const key = await existingKey(authority, pending?.jkt);
1108
+ authority.assertCurrent();
597
1109
  if (!pending?.jkt || typeof code !== "string" || code.length === 0) throw loginRequired();
598
- const operationEpoch = epoch;
599
- const result = await acceptResponse(await post("exchange", { code }, key), key, operationEpoch);
1110
+ const result = await acceptResponse(await post(authority, "exchange", { code }, key), key, authority);
1111
+ authority.assertCurrent();
600
1112
  store(pendingKey);
601
1113
  return result;
602
1114
  });
603
1115
  },
604
1116
  refresh(refreshOptions = {}) {
605
- assertLive();
1117
+ const authority = lifecycle.capture();
1118
+ resetPromise = void 0;
606
1119
  if (refreshPromise) return refreshPromise;
607
- const run = async () => {
608
- const hadToken = token !== void 0;
609
- const baseline = token?.generation ?? (channel ? (await readCookieSessionVersion(scope.id))?.generation : void 0);
610
- return operation(async () => {
611
- const key = await authenticatedKey();
612
- if (channel) {
613
- const recognition = await currentRecognition();
614
- const version = await readCookieSessionVersion(scope.id);
615
- if (!version?.active || version.jkt !== key.jkt) throw loginRequired();
616
- const current = token;
617
- if (refreshOptions.rejectedToken && current && current.accessToken !== refreshOptions.rejectedToken && current.expiresAt > now() && current.generation === version.generation && tokenRecognition === recognition)
618
- return current;
619
- if ((!hadToken || current?.generation !== baseline) && current && current.expiresAt > now() && current.accessToken !== refreshOptions.rejectedToken && current.generation === version.generation && tokenRecognition === recognition)
620
- return current;
621
- if (!hadToken || version.generation !== baseline) {
622
- const peer = await requestPeer(key.jkt, version.generation, recognition, refreshOptions.rejectedToken);
623
- if (peer) return peer;
624
- }
1120
+ const { rejectedToken } = refreshOptions;
1121
+ const hadToken = token !== void 0;
1122
+ const baseline = token?.generation;
1123
+ const pending = operation(authority, async () => {
1124
+ const key = await authenticatedKey(authority);
1125
+ authority.assertCurrent();
1126
+ if (channel) {
1127
+ const recognition = await currentRecognition(authority);
1128
+ authority.assertCurrent();
1129
+ const version = await readCookieSessionVersion(scope.id);
1130
+ authority.assertCurrent();
1131
+ if (!version?.active || version.jkt !== key.jkt) throw loginRequired();
1132
+ const reusable = () => {
1133
+ const current2 = token;
1134
+ if (current2?.jkt === key.jkt && current2.accessToken !== rejectedToken && current2.expiresAt > now() && current2.generation === version.generation && tokenRecognition === recognition)
1135
+ return current2;
1136
+ };
1137
+ const current = reusable();
1138
+ if (current && (rejectedToken || !hadToken || current.generation !== baseline)) return current;
1139
+ if (!hadToken || version.generation !== baseline) {
1140
+ const peer = await requestPeer(authority, key.jkt, version.generation, recognition, rejectedToken);
1141
+ authority.assertCurrent();
1142
+ if (peer) return peer;
1143
+ const winner = reusable();
1144
+ if (winner) return winner;
625
1145
  }
626
- if (!channel && refreshOptions.rejectedToken && token && token.accessToken !== refreshOptions.rejectedToken && token.expiresAt > now() && tokenRecognition === await currentRecognition())
627
- return token;
628
- const handle = sessionTransport === "body" ? readStorage(storageKey) : void 0;
629
- if (sessionTransport === "body" && (!handle?.sessionId || handle.jkt !== key.jkt)) throw loginRequired();
630
- const operationEpoch = epoch;
631
- return acceptResponse(
632
- await post("refresh", handle ? { sessionId: handle.sessionId } : {}, key),
633
- key,
634
- operationEpoch
635
- );
636
- });
637
- };
638
- const pending = run();
1146
+ }
1147
+ if (!channel && rejectedToken && token && token.accessToken !== rejectedToken && token.expiresAt > now() && tokenRecognition === await currentRecognition(authority)) {
1148
+ authority.assertCurrent();
1149
+ return token;
1150
+ }
1151
+ authority.assertCurrent();
1152
+ const handle = sessionTransport === "body" ? readStorage(storageKey) : void 0;
1153
+ if (sessionTransport === "body" && (!handle?.sessionId || handle.jkt !== key.jkt)) throw loginRequired();
1154
+ return acceptResponse(
1155
+ await post(authority, "refresh", handle ? { sessionId: handle.sessionId } : {}, key),
1156
+ key,
1157
+ authority
1158
+ );
1159
+ });
639
1160
  refreshPromise = pending;
640
1161
  void pending.finally(() => {
641
1162
  if (refreshPromise === pending) refreshPromise = void 0;
@@ -644,34 +1165,198 @@ var createOidcVaultDpopSession = (options) => {
644
1165
  return pending;
645
1166
  },
646
1167
  async logout() {
647
- assertLive();
648
- await operation(async () => {
649
- const key = await authenticatedKey();
1168
+ const authority = lifecycle.capture();
1169
+ refreshPromise = void 0;
1170
+ resetPromise = void 0;
1171
+ await operation(authority, async () => {
1172
+ const key = await authenticatedKey(authority);
1173
+ authority.assertCurrent();
650
1174
  const handle = sessionTransport === "body" ? readStorage(storageKey) : void 0;
651
1175
  if (sessionTransport === "body" && !handle?.sessionId) throw loginRequired();
652
- const { value } = await post("logout", handle ? { sessionId: handle.sessionId } : {}, key);
1176
+ const { value } = await post(authority, "logout", handle ? { sessionId: handle.sessionId } : {}, key);
1177
+ authority.assertCurrent();
653
1178
  if (typeof value !== "object" || value === null || !("loggedOut" in value) || value.loggedOut !== true) {
654
1179
  throw new OidcVaultDpopClientError("INVALID_LOGOUT_RESPONSE", "The logout response is invalid.");
655
1180
  }
656
- await invalidate();
1181
+ clearLocal();
1182
+ await invalidateCookie(authority, key.jkt);
657
1183
  });
658
1184
  },
659
1185
  getAccessToken: () => token,
660
- getKey: () => operation(authenticatedKey),
661
- clear: () => withCookieLock(invalidate),
1186
+ async getKey() {
1187
+ const authority = stateAuthority();
1188
+ try {
1189
+ const pending = !token ? readStorage(pendingKey) : void 0;
1190
+ if (pending) {
1191
+ await existingKey(authority, pending.jkt);
1192
+ authority.assertCurrent();
1193
+ throw loginRequired();
1194
+ }
1195
+ return await authenticatedKey(authority);
1196
+ } catch (error) {
1197
+ authority.assertCurrent();
1198
+ if (error instanceof OidcVaultDpopClientError && error.code === "LOGIN_REQUIRED") throw error;
1199
+ return operation(authority, async () => {
1200
+ throw error;
1201
+ });
1202
+ }
1203
+ },
1204
+ async clear() {
1205
+ const oldJkt = token?.jkt;
1206
+ const authority = invalidateLocal();
1207
+ if (!channel) return;
1208
+ await lifecycle.enqueue(authority, () => withCookieLock(() => invalidateCookie(authority, oldJkt)));
1209
+ },
1210
+ async resetKey() {
1211
+ if (resetPromise) return resetPromise;
1212
+ const oldJkt = token?.jkt;
1213
+ const authority = invalidateLocal();
1214
+ const pending = lifecycle.enqueue(
1215
+ authority,
1216
+ () => withCookieLock(async () => {
1217
+ authority.assertCurrent();
1218
+ const previous = await removeDpopScope(scope.id, authority);
1219
+ authority.assertCurrent();
1220
+ if (previous.keyPresent || previous.cookiePresent)
1221
+ channel?.postMessage({
1222
+ type: "reset-key",
1223
+ sender,
1224
+ jkt: previous.version?.jkt ?? oldJkt ?? "",
1225
+ generation: previous.version?.generation ?? "",
1226
+ keyJkt: previous.keyJkt
1227
+ });
1228
+ })
1229
+ );
1230
+ resetPromise = pending;
1231
+ void pending.finally(() => {
1232
+ if (resetPromise === pending) resetPromise = void 0;
1233
+ }).catch(() => {
1234
+ });
1235
+ await pending;
1236
+ },
662
1237
  dispose() {
663
- disposed = true;
664
- epoch++;
1238
+ lifecycle.dispose();
1239
+ publication++;
1240
+ refreshPromise = void 0;
1241
+ resetPromise = void 0;
665
1242
  token = void 0;
666
1243
  tokenRecognition = void 0;
1244
+ nonces.clear();
1245
+ for (const waiter of peerWaiters) waiter.finish();
1246
+ if (channel) channel.onmessage = null;
667
1247
  channel?.close();
668
- peerWaiters.clear();
669
1248
  }
670
1249
  };
671
1250
  return Object.freeze(session);
672
1251
  };
673
1252
 
1253
+ // src/auth-challenge.ts
1254
+ var TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
1255
+ var TOKEN68 = /^[A-Za-z0-9._~+/-]+=*$/;
1256
+ var PARAMETER = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+)[ \t]*=[ \t]*(.*)$/;
1257
+ var CHALLENGE = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+)(?: +(.+))?$/;
1258
+ var partsOf = (header) => {
1259
+ const parts = [];
1260
+ let start = 0;
1261
+ const addPart = (end) => {
1262
+ while (start < end && (header[start] === " " || header[start] === " ")) start++;
1263
+ while (end > start && (header[end - 1] === " " || header[end - 1] === " ")) end--;
1264
+ parts.push(header.slice(start, end));
1265
+ };
1266
+ let quoted = false;
1267
+ let escaped = false;
1268
+ for (let index = 0; index < header.length; index++) {
1269
+ const character = header[index];
1270
+ if (escaped) escaped = false;
1271
+ else if (quoted && character === "\\") escaped = true;
1272
+ else if (character === '"') quoted = !quoted;
1273
+ else if (!quoted && character === ",") {
1274
+ addPart(index);
1275
+ start = index + 1;
1276
+ }
1277
+ }
1278
+ if (quoted || escaped) return void 0;
1279
+ addPart(header.length);
1280
+ return parts;
1281
+ };
1282
+ var parameterOf = (part) => {
1283
+ const match = PARAMETER.exec(part);
1284
+ if (!match) return void 0;
1285
+ const name = match[1].toLowerCase();
1286
+ const raw = match[2];
1287
+ if (TOKEN.test(raw)) return [name, raw];
1288
+ if (raw[0] !== '"') return void 0;
1289
+ let value = "";
1290
+ for (let index = 1; index < raw.length; index++) {
1291
+ const character = raw[index];
1292
+ if (character === '"') return index === raw.length - 1 ? [name, value] : void 0;
1293
+ if (character === "\\") {
1294
+ if (++index === raw.length) return void 0;
1295
+ value += raw[index];
1296
+ } else value += character;
1297
+ }
1298
+ return void 0;
1299
+ };
1300
+ var readDpopChallengeError = (header) => {
1301
+ if (typeof header !== "string" || header.length > 8192 || !/^[\t\x20-\x7e\x80-\xff]*$/.test(header)) return void 0;
1302
+ const parts = partsOf(header);
1303
+ if (!parts) return void 0;
1304
+ let parameters;
1305
+ let dpopParameters;
1306
+ let seenDpop = false;
1307
+ for (const part of parts) {
1308
+ const parameter = parameterOf(part);
1309
+ if (parameter) {
1310
+ if (!parameters || parameters.has(parameter[0])) return void 0;
1311
+ parameters.set(...parameter);
1312
+ continue;
1313
+ }
1314
+ const challenge = CHALLENGE.exec(part);
1315
+ if (!challenge) return void 0;
1316
+ const isDpop = challenge[1].toLowerCase() === "dpop";
1317
+ if (isDpop && seenDpop) return void 0;
1318
+ if (isDpop) seenDpop = true;
1319
+ parameters = void 0;
1320
+ const credentials = challenge[2];
1321
+ if (credentials !== void 0) {
1322
+ const first = parameterOf(credentials);
1323
+ if (first) parameters = new Map([first]);
1324
+ else if (!TOKEN68.test(credentials)) return void 0;
1325
+ }
1326
+ if (isDpop) dpopParameters = parameters;
1327
+ }
1328
+ const error = dpopParameters?.get("error");
1329
+ return error === "use_dpop_nonce" || error === "invalid_token" ? error : void 0;
1330
+ };
1331
+
674
1332
  // src/auth-fetch.ts
1333
+ var invalidApiConfig = () => new OidcVaultDpopClientError("INVALID_DPOP_API_CONFIG", "The API auth scope configuration is invalid or ambiguous.");
1334
+ var selectApi = (apis, origin, pathname) => {
1335
+ if (!Array.isArray(apis)) throw invalidApiConfig();
1336
+ const scopes = /* @__PURE__ */ new Set();
1337
+ let selected;
1338
+ for (const candidate of apis) {
1339
+ if (!candidate || typeof candidate !== "object") throw invalidApiConfig();
1340
+ const { origin: configuredOrigin, pathPrefix: configuredPath, replayNamespace, credentials = "omit" } = candidate;
1341
+ if (typeof configuredOrigin !== "string" || typeof replayNamespace !== "string" || !/^[\x20-\x7e]{1,128}$/.test(replayNamespace) || credentials !== "omit" && credentials !== "include")
1342
+ throw invalidApiConfig();
1343
+ const normalizedOrigin = normalizeStaticOrigin(configuredOrigin);
1344
+ const pathPrefix = normalizeDpopPathPrefix(normalizedOrigin, configuredPath);
1345
+ const scope = JSON.stringify([normalizedOrigin, pathPrefix]);
1346
+ if (scopes.has(scope)) throw invalidApiConfig();
1347
+ scopes.add(scope);
1348
+ if (normalizedOrigin !== origin || pathPrefix !== "/" && pathname !== pathPrefix && !pathname.startsWith(`${pathPrefix}/`)) {
1349
+ continue;
1350
+ }
1351
+ if (!selected || pathPrefix.length > selected.pathPrefix.length) {
1352
+ selected = { origin: normalizedOrigin, pathPrefix, replayNamespace, credentials };
1353
+ }
1354
+ }
1355
+ if (!selected) {
1356
+ throw new OidcVaultDpopClientError("DPOP_API_OUT_OF_SCOPE", "The API target is outside the configured auth scope.");
1357
+ }
1358
+ return selected;
1359
+ };
675
1360
  var defaultNonces = /* @__PURE__ */ new WeakMap();
676
1361
  var nonceCache = (context) => {
677
1362
  if (context.nonces) return context.nonces;
@@ -710,14 +1395,24 @@ var replayableBody = (body) => {
710
1395
  }
711
1396
  throw new OidcVaultDpopClientError("DPOP_BODY_NOT_REPLAYABLE", "Retry requires a replayable request body.");
712
1397
  };
713
- var challengeError = (response) => response.status === 401 ? /(?:^|,\s*)DPoP\s+error="([a-z_]+)"(?:,|$)/i.exec(response.headers.get("WWW-Authenticate") ?? "")?.[1] : void 0;
1398
+ var assertLiveAccessToken = (expiresAt, now) => {
1399
+ if (!Number.isSafeInteger(now) || now < 0)
1400
+ throw new OidcVaultDpopClientError("INVALID_DPOP_TIME", "The proof clock is invalid.");
1401
+ if (!Number.isSafeInteger(expiresAt) || expiresAt <= now)
1402
+ throw new OidcVaultDpopClientError(
1403
+ "DPOP_ACCESS_TOKEN_EXPIRED",
1404
+ "The DPoP access token has expired. Refresh before retrying."
1405
+ );
1406
+ };
714
1407
  var fetchWithDpop = async (context, input, options = {}) => {
715
- const target = normalizeDpopTarget(input);
716
- const url = new URL(typeof input === "string" ? input : input.href);
717
- const api = context.apis.find((candidate) => normalizeStaticOrigin(candidate.origin) === url.origin);
718
- if (!api || !api.replayNamespace || api.replayNamespace.length > 128 || !/^[\x20-\x7e]+$/.test(api.replayNamespace)) {
719
- throw new OidcVaultDpopClientError("DPOP_API_OUT_OF_SCOPE", "The API target is outside the configured auth scope.");
720
- }
1408
+ const { signal } = options;
1409
+ assertNotAborted(signal);
1410
+ const inputUrl = typeof input === "string" ? input : input.href;
1411
+ const target = normalizeDpopTarget(inputUrl);
1412
+ const url = new URL(inputUrl);
1413
+ url.pathname = new URL(target).pathname;
1414
+ const { href, origin, pathname } = url;
1415
+ const api = selectApi(context.apis, origin, pathname);
721
1416
  const { session, fetch: request = globalThis.fetch.bind(globalThis), now = Date.now } = context;
722
1417
  const { retry, ...init } = options;
723
1418
  const method = (init.method ?? "GET").toUpperCase();
@@ -730,58 +1425,80 @@ var fetchWithDpop = async (context, input, options = {}) => {
730
1425
  if (headers.has(name))
731
1426
  throw new OidcVaultDpopClientError("DPOP_AUTH_HEADER_OVERRIDE", "The DPoP helper owns authentication headers.");
732
1427
  }
733
- const space = JSON.stringify(["api", url.origin, api.replayNamespace]);
1428
+ const space = JSON.stringify(["api", api.origin, api.replayNamespace]);
734
1429
  const nonces = nonceCache(context);
735
1430
  let nonceRetried = false;
736
1431
  let refreshed = false;
737
1432
  let credential = session.getAccessToken();
738
1433
  if (!credential || credential.expiresAt <= now()) {
739
- credential = await session.refresh();
1434
+ credential = await abortable(signal, () => session.refresh());
740
1435
  refreshed = true;
741
1436
  }
742
1437
  for (; ; ) {
743
- if (options.signal?.aborted) throw options.signal.reason;
1438
+ assertNotAborted(signal);
744
1439
  if (credential.tokenType !== "DPoP") {
745
- await session.clear();
1440
+ await abortable(signal, () => session.clear());
1441
+ assertNotAborted(signal);
746
1442
  throw new OidcVaultDpopClientError(
747
1443
  "DPOP_TOKEN_TYPE_REQUIRED",
748
1444
  "A DPoP access token is required. Sign in again.",
749
1445
  true
750
1446
  );
751
1447
  }
752
- const key = await session.getKey();
753
- if (key.jkt !== credential.jkt) {
754
- await session.clear();
1448
+ assertLiveAccessToken(credential.expiresAt, now());
1449
+ const accessToken = credential.accessToken;
1450
+ const key = await abortable(signal, () => session.getKey());
1451
+ assertNotAborted(signal);
1452
+ const { jkt } = key;
1453
+ if (jkt !== credential.jkt) {
1454
+ await abortable(signal, () => session.clear());
1455
+ assertNotAborted(signal);
755
1456
  throw loginRequired();
756
1457
  }
757
- const proof = await createDpopProof(key, {
758
- method,
759
- url: target,
760
- accessToken: credential.accessToken,
761
- nonce: nonces.get(space, key.jkt),
762
- now
763
- });
1458
+ const proof = await abortable(
1459
+ signal,
1460
+ () => createDpopProof(key, {
1461
+ method,
1462
+ url: target,
1463
+ accessToken,
1464
+ nonce: nonces.get(space, jkt),
1465
+ now,
1466
+ signal: signal ?? void 0
1467
+ })
1468
+ );
1469
+ assertNotAborted(signal);
1470
+ assertLiveAccessToken(credential.expiresAt, now());
764
1471
  const attemptHeaders = new Headers(headers);
765
- attemptHeaders.set("Authorization", `DPoP ${credential.accessToken}`);
1472
+ attemptHeaders.set("Authorization", `DPoP ${accessToken}`);
766
1473
  attemptHeaders.set("DPoP", proof);
767
- const response = await request(url.href, {
768
- ...init,
769
- method,
770
- body: body(),
771
- headers: attemptHeaders,
772
- credentials: api.credentials ?? "omit",
773
- redirect: "error",
774
- cache: "no-store"
775
- });
776
- if (challengeError(response) === "use_dpop_nonce" && canRetry && !nonceRetried && nonces.remember(space, key.jkt, response.headers.get("DPoP-Nonce"))) {
1474
+ const response = await abortable(
1475
+ signal,
1476
+ () => request(href, {
1477
+ ...init,
1478
+ method,
1479
+ body: body(),
1480
+ headers: attemptHeaders,
1481
+ credentials: api.credentials,
1482
+ redirect: "error",
1483
+ cache: "no-store"
1484
+ }),
1485
+ cancelResponse
1486
+ );
1487
+ if (signal?.aborted) {
1488
+ cancelResponse(response);
1489
+ assertNotAborted(signal);
1490
+ }
1491
+ const challenge = response.status === 401 ? readDpopChallengeError(response.headers.get("WWW-Authenticate")) : void 0;
1492
+ const learnedNonce = nonces.remember(space, jkt, response.headers.get("DPoP-Nonce"));
1493
+ if (challenge === "use_dpop_nonce" && canRetry && !nonceRetried && learnedNonce) {
777
1494
  nonceRetried = true;
778
- await response.body?.cancel();
1495
+ cancelResponse(response);
779
1496
  continue;
780
1497
  }
781
- if (challengeError(response) === "invalid_token" && canRetry && !refreshed) {
1498
+ if (challenge === "invalid_token" && canRetry && !refreshed) {
782
1499
  refreshed = true;
783
- await response.body?.cancel();
784
- credential = await session.refresh({ rejectedToken: credential.accessToken });
1500
+ cancelResponse(response);
1501
+ credential = await abortable(signal, () => session.refresh({ rejectedToken: accessToken }));
785
1502
  continue;
786
1503
  }
787
1504
  return response;