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