@web-ts-toolkit/oidc-vault-dpop-client 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs ADDED
@@ -0,0 +1,842 @@
1
+ // src/dpop-key-store.ts
2
+ import { calculateJwkThumbprint } from "jose";
3
+
4
+ // src/errors.ts
5
+ var OidcVaultDpopClientError = class extends Error {
6
+ constructor(code, message, requiresLogin = false, status) {
7
+ super(message);
8
+ this.code = code;
9
+ this.requiresLogin = requiresLogin;
10
+ this.status = status;
11
+ this.name = "OidcVaultDpopClientError";
12
+ }
13
+ };
14
+ var keyLost = () => new OidcVaultDpopClientError("DPOP_KEY_LOST", "The browser login key is missing or changed. Sign in again.", true);
15
+ var loginRequired = () => new OidcVaultDpopClientError("LOGIN_REQUIRED", "Sign in to continue.", true);
16
+ var SERVER_MESSAGES = Object.freeze({
17
+ OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED: "Browser recognition changed; sign in again.",
18
+ OIDC_VAULT_INVALID_FINGERPRINT: "Fingerprint signal is invalid.",
19
+ OIDC_VAULT_USE_DPOP_NONCE: "A fresh DPoP nonce is required.",
20
+ OIDC_VAULT_INVALID_DPOP_PROOF: "DPoP proof validation failed.",
21
+ 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."
26
+ });
27
+ 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);
31
+ };
32
+
33
+ // src/key-database.ts
34
+ import { openDB } from "idb";
35
+ var DPOP_DATABASE_NAME = "oidc-vault-dpop-example-v1";
36
+ var withDpopDatabase = async (operation) => {
37
+ let db;
38
+ try {
39
+ db = await openDB(DPOP_DATABASE_NAME, 1, {
40
+ upgrade(database) {
41
+ database.createObjectStore("keys");
42
+ database.createObjectStore("cookieSessions");
43
+ },
44
+ blocked() {
45
+ }
46
+ });
47
+ } catch {
48
+ throw new OidcVaultDpopClientError(
49
+ "DPOP_KEY_STORAGE_UNAVAILABLE",
50
+ "IndexedDB key persistence is unavailable.",
51
+ true
52
+ );
53
+ }
54
+ try {
55
+ return await operation(db);
56
+ } finally {
57
+ db.close();
58
+ }
59
+ };
60
+ 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
+ });
64
+
65
+ // src/scope.ts
66
+ var invalidTarget = () => new OidcVaultDpopClientError("INVALID_DPOP_TARGET", "A pinned absolute HTTP(S) target is required.");
67
+ var hasControl = (value) => [...value].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
68
+ var normalizePath = (origin, path) => {
69
+ if (!path.startsWith("/") || /[\s\\]/.test(path) || hasControl(path) || /%(?![0-9a-f]{2})/i.test(path))
70
+ throw invalidTarget();
71
+ const decoded = path.replace(/%([0-9a-f]{2})/gi, (_, hex) => {
72
+ const character = String.fromCharCode(Number.parseInt(hex, 16));
73
+ return /^[A-Za-z0-9._~-]$/.test(character) ? character : `%${hex.toUpperCase()}`;
74
+ });
75
+ return new URL(`${origin}${decoded}`).pathname;
76
+ };
77
+ var normalizeDpopTarget = (value) => {
78
+ const input = typeof value === "string" ? value : value.href;
79
+ if (/[\s\\]/.test(input) || hasControl(input) || !/^https?:\/\/[^/\\?#]+/i.test(input)) throw invalidTarget();
80
+ let url;
81
+ try {
82
+ url = new URL(input);
83
+ } catch {
84
+ throw invalidTarget();
85
+ }
86
+ const queryFree = input.split(/[?#]/, 1)[0];
87
+ const authority = queryFree.slice(queryFree.indexOf("://") + 3).split("/", 1)[0];
88
+ if (url.username || url.password || url.origin === "null" || authority.includes("@")) throw invalidTarget();
89
+ const authorityEnd = queryFree.indexOf("/", queryFree.indexOf("://") + 3);
90
+ const path = authorityEnd === -1 ? "/" : queryFree.slice(authorityEnd);
91
+ return `${url.origin}${normalizePath(url.origin, path)}`;
92
+ };
93
+ var normalizeStaticOrigin = (value) => {
94
+ const target = normalizeDpopTarget(value);
95
+ const url = new URL(value);
96
+ if (url.pathname !== "/" || url.search || url.hash || target !== `${url.origin}/`) throw invalidTarget();
97
+ if (url.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
98
+ throw new OidcVaultDpopClientError("INSECURE_DPOP_ORIGIN", "DPoP requires HTTPS or loopback development.");
99
+ }
100
+ return url.origin;
101
+ };
102
+ var resolveDpopScope = (scope) => {
103
+ const frontendOrigin = normalizeStaticOrigin(scope.frontendOrigin);
104
+ const backendOrigin = normalizeStaticOrigin(scope.backendOrigin);
105
+ if (!scope.basePath.startsWith("/") || /[?#]/.test(scope.basePath)) throw invalidTarget();
106
+ const basePath = normalizePath(backendOrigin, scope.basePath).replace(/\/+$/g, "") || "/";
107
+ return Object.freeze({
108
+ frontendOrigin,
109
+ backendOrigin,
110
+ basePath,
111
+ id: JSON.stringify(["oidc-vault-dpop-v1", frontendOrigin, backendOrigin, basePath])
112
+ });
113
+ };
114
+ var assertDpopBrowserFeatures = (cookieTransport = false) => {
115
+ if (globalThis.isSecureContext !== true || !globalThis.crypto?.subtle || typeof globalThis.crypto.randomUUID !== "function" || typeof globalThis.CryptoKey !== "function" || !globalThis.indexedDB) {
116
+ throw new OidcVaultDpopClientError(
117
+ "DPOP_BROWSER_UNSUPPORTED",
118
+ "A secure context, Web Crypto and IndexedDB are required."
119
+ );
120
+ }
121
+ if (cookieTransport && (!globalThis.navigator?.locks?.request || typeof globalThis.BroadcastChannel !== "function")) {
122
+ throw new OidcVaultDpopClientError(
123
+ "DPOP_TAB_COORDINATION_UNSUPPORTED",
124
+ "Cookie sessions require Web Locks and BroadcastChannel."
125
+ );
126
+ }
127
+ };
128
+
129
+ // src/dpop-key-store.ts
130
+ var validateKey = async (value, scopeId) => {
131
+ const algorithm = value?.privateKey?.algorithm;
132
+ const jwk = value?.publicJwk;
133
+ if (value?.version !== 1 || value.scopeId !== scopeId || !(value.privateKey instanceof CryptoKey) || value.privateKey.type !== "private" || value.privateKey.extractable || value.privateKey.usages.length !== 1 || value.privateKey.usages[0] !== "sign" || algorithm?.name !== "ECDSA" || algorithm.namedCurve !== "P-256" || jwk?.kty !== "EC" || jwk.crv !== "P-256" || Object.keys(jwk).length !== 4 || !/^[A-Za-z0-9_-]{43}$/.test(jwk.x) || !/^[A-Za-z0-9_-]{43}$/.test(jwk.y) || await calculateJwkThumbprint(jwk) !== value.jkt)
134
+ throw keyLost();
135
+ const challenge = new TextEncoder().encode("oidc-vault-dpop-key-pair-v1");
136
+ const publicKey = await crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
137
+ const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, value.privateKey, challenge);
138
+ if (!await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, publicKey, signature, challenge))
139
+ throw keyLost();
140
+ return Object.freeze({ privateKey: value.privateKey, publicJwk: Object.freeze({ ...jwk }), jkt: value.jkt, scopeId });
141
+ };
142
+ var getOrCreateDpopKey = async (input, options = {}) => {
143
+ assertDpopBrowserFeatures();
144
+ const { id } = resolveDpopScope(input);
145
+ 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();
149
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
150
+ const exported = await crypto.subtle.exportKey("jwk", pair.publicKey);
151
+ if (!exported.x || !exported.y) throw keyLost();
152
+ const publicJwk = { kty: "EC", crv: "P-256", x: exported.x, y: exported.y };
153
+ const candidate = {
154
+ version: 1,
155
+ scopeId: id,
156
+ privateKey: pair.privateKey,
157
+ publicJwk,
158
+ jkt: await calculateJwkThumbprint(publicJwk)
159
+ };
160
+ const winner = await withDpopDatabase(async (db) => {
161
+ 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;
166
+ });
167
+ return await validateKey(winner, id);
168
+ } catch (error) {
169
+ if (error instanceof OidcVaultDpopClientError) throw error;
170
+ throw new OidcVaultDpopClientError(
171
+ "DPOP_KEY_STORAGE_UNAVAILABLE",
172
+ "Non-extractable IndexedDB key persistence is unavailable.",
173
+ true
174
+ );
175
+ }
176
+ };
177
+
178
+ // src/dpop-proof.ts
179
+ import { base64url, SignJWT } from "jose";
180
+ var createDpopProof = async (key, input) => {
181
+ const { method, url, accessToken, nonce, now = Date.now } = input;
182
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(method) || method !== method.toUpperCase()) {
183
+ throw new OidcVaultDpopClientError("INVALID_DPOP_METHOD", "Use the exact uppercase request method.");
184
+ }
185
+ const iat = Math.floor(now() / 1e3);
186
+ if (!Number.isSafeInteger(iat) || iat < 0)
187
+ throw new OidcVaultDpopClientError("INVALID_DPOP_TIME", "The proof clock is invalid.");
188
+ if (nonce !== void 0 && (nonce.length === 0 || nonce.length > 512 || !/^[\x20-\x7e]+$/.test(nonce))) {
189
+ throw new OidcVaultDpopClientError("INVALID_DPOP_NONCE", "The DPoP nonce is invalid.");
190
+ }
191
+ let ath;
192
+ if (accessToken !== void 0) {
193
+ if (!/^[\x21-\x7e]+$/.test(accessToken))
194
+ throw new OidcVaultDpopClientError("INVALID_DPOP_TOKEN", "The access token is invalid.");
195
+ ath = base64url.encode(
196
+ new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken)))
197
+ );
198
+ }
199
+ 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);
208
+ };
209
+
210
+ // src/auth-session.ts
211
+ import { base64url as base64url2 } from "jose";
212
+
213
+ // src/credentials.ts
214
+ import { decodeJwt, decodeProtectedHeader } from "jose";
215
+ var invalidCredentials = () => new OidcVaultDpopClientError(
216
+ "INVALID_BOUND_CREDENTIAL_RESPONSE",
217
+ "A matching DPoP JWT response is required. Sign in again.",
218
+ true
219
+ );
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;
226
+ try {
227
+ const header = decodeProtectedHeader(accessToken);
228
+ const claims = decodeJwt(accessToken);
229
+ 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)
231
+ throw invalidCredentials();
232
+ expiration = Math.min(now + expiresIn * 1e3, claims.exp * 1e3);
233
+ if (!Number.isSafeInteger(expiration)) throw invalidCredentials();
234
+ } catch {
235
+ throw invalidCredentials();
236
+ }
237
+ if (user !== void 0 && (typeof user !== "object" || user === null || Array.isArray(user) || !("sub" in user) || typeof user.sub !== "string"))
238
+ throw invalidCredentials();
239
+ const token = Object.freeze({
240
+ accessToken,
241
+ tokenType,
242
+ expiresAt: expiration,
243
+ jkt,
244
+ generation: crypto.randomUUID(),
245
+ ...user === void 0 ? {} : { user: structuredClone(user) }
246
+ });
247
+ return { token, ...transport === "body" ? { sessionId } : {} };
248
+ };
249
+ 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)
253
+ return void 0;
254
+ 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 });
267
+ } catch {
268
+ return void 0;
269
+ }
270
+ };
271
+
272
+ // src/nonce-cache.ts
273
+ var DpopNonceCache = class {
274
+ constructor() {
275
+ this.nonces = /* @__PURE__ */ new Map();
276
+ }
277
+ get(space, jkt) {
278
+ return this.nonces.get(JSON.stringify([space, jkt]));
279
+ }
280
+ 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);
283
+ return true;
284
+ }
285
+ clear() {
286
+ this.nonces.clear();
287
+ }
288
+ };
289
+
290
+ // src/auth-session.ts
291
+ var createOidcVaultDpopSession = (options) => {
292
+ const {
293
+ backendOrigin,
294
+ basePath = "/auth/oidc",
295
+ sessionTransport = "body",
296
+ fingerprint,
297
+ fetch: request = globalThis.fetch.bind(globalThis),
298
+ navigate = (url) => location.assign(url),
299
+ now = Date.now
300
+ } = options;
301
+ if (sessionTransport !== "body" && sessionTransport !== "cookie") throw new TypeError("Unknown session transport.");
302
+ assertDpopBrowserFeatures(sessionTransport === "cookie");
303
+ const scope = resolveDpopScope({ frontendOrigin: location.origin, backendOrigin, basePath });
304
+ const storageKey = `oidc-vault-dpop:handle:${scope.id}`;
305
+ const pendingKey = `oidc-vault-dpop:pending:${scope.id}`;
306
+ const lockName = `oidc-vault-dpop:cookie:${scope.id}`;
307
+ const sender = crypto.randomUUID();
308
+ const nonces = new DpopNonceCache();
309
+ const channel = sessionTransport === "cookie" ? new BroadcastChannel(lockName) : void 0;
310
+ let token;
311
+ let tokenRecognition;
312
+ let refreshPromise;
313
+ let disposed = false;
314
+ let epoch = 0;
315
+ const peerWaiters = /* @__PURE__ */ new Set();
316
+ const readStorage = (key) => {
317
+ try {
318
+ const value = sessionStorage.getItem(key);
319
+ return value === null ? void 0 : JSON.parse(value);
320
+ } catch {
321
+ throw new OidcVaultDpopClientError(
322
+ "DPOP_SESSION_STORAGE_UNAVAILABLE",
323
+ "Session storage is unavailable. Sign in again.",
324
+ true
325
+ );
326
+ }
327
+ };
328
+ const store = (key, value) => {
329
+ try {
330
+ if (value === void 0) sessionStorage.removeItem(key);
331
+ else sessionStorage.setItem(key, JSON.stringify(value));
332
+ } catch {
333
+ throw new OidcVaultDpopClientError(
334
+ "DPOP_SESSION_STORAGE_UNAVAILABLE",
335
+ "Session storage is unavailable. Sign in again.",
336
+ true
337
+ );
338
+ }
339
+ };
340
+ readStorage(pendingKey);
341
+ const clearLocal = (clearPendingLogin = true) => {
342
+ token = void 0;
343
+ tokenRecognition = void 0;
344
+ epoch++;
345
+ nonces.clear();
346
+ store(storageKey);
347
+ if (clearPendingLogin) store(pendingKey);
348
+ };
349
+ const assertLive = () => {
350
+ if (disposed) throw new OidcVaultDpopClientError("DPOP_SESSION_DISPOSED", "This browser auth context is closed.");
351
+ };
352
+ const withCookieLock = (operation2) => sessionTransport === "cookie" ? navigator.locks.request(lockName, { mode: "exclusive" }, operation2) : operation2();
353
+ const invalidate = async () => {
354
+ const oldJkt = token?.jkt;
355
+ clearLocal();
356
+ if (channel) {
357
+ const previous = await readCookieSessionVersion(scope.id);
358
+ const jkt = oldJkt ?? previous?.jkt ?? "";
359
+ const generation = crypto.randomUUID();
360
+ await writeCookieSessionVersion(scope.id, { jkt, generation, active: false });
361
+ channel.postMessage({ type: "clear", sender, jkt, generation });
362
+ }
363
+ };
364
+ const existingKey = async (expectedJkt) => {
365
+ assertLive();
366
+ const key = await getOrCreateDpopKey(scope, { create: false });
367
+ if (expectedJkt !== void 0 && expectedJkt !== key.jkt) throw keyLost();
368
+ return key;
369
+ };
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);
373
+ if (!expected) throw loginRequired();
374
+ return key;
375
+ };
376
+ const recognitionMarker = async (headers) => base64url2.encode(
377
+ new Uint8Array(
378
+ await crypto.subtle.digest(
379
+ "SHA-256",
380
+ new TextEncoder().encode(
381
+ JSON.stringify(
382
+ Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]).sort(([a], [b]) => a.localeCompare(b))
383
+ )
384
+ )
385
+ )
386
+ )
387
+ );
388
+ const currentRecognition = async () => recognitionMarker(await fingerprint?.headers() ?? {});
389
+ const post = async (route, body, key) => {
390
+ const url = `${scope.backendOrigin}${scope.basePath === "/" ? "" : scope.basePath}/${route}`;
391
+ for (let attempt = 0; attempt < 2; attempt++) {
392
+ const recognition = route === "logout" ? {} : await fingerprint?.headers() ?? {};
393
+ const currentKey = await existingKey(key.jkt);
394
+ const proof = await createDpopProof(currentKey, {
395
+ method: "POST",
396
+ url,
397
+ 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)
407
+ });
408
+ let value;
409
+ try {
410
+ value = await response.json();
411
+ } catch {
412
+ value = void 0;
413
+ }
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
+ throw error;
419
+ }
420
+ throw new OidcVaultDpopClientError("DPOP_NONCE_RETRY_EXHAUSTED", "The DPoP nonce challenge was repeated.");
421
+ };
422
+ const publish = async (next, recognition, operationEpoch) => {
423
+ await existingKey(next.jkt);
424
+ if (epoch !== operationEpoch || disposed) throw loginRequired();
425
+ if (channel) {
426
+ await writeCookieSessionVersion(scope.id, { jkt: next.jkt, generation: next.generation, active: true });
427
+ }
428
+ if (epoch !== operationEpoch || disposed) throw loginRequired();
429
+ token = next;
430
+ tokenRecognition = recognition;
431
+ channel?.postMessage({
432
+ type: "token",
433
+ sender,
434
+ jkt: next.jkt,
435
+ generation: next.generation,
436
+ token: next,
437
+ recognition
438
+ });
439
+ return next;
440
+ };
441
+ const acceptResponse = async (response, key, operationEpoch) => {
442
+ if (epoch !== operationEpoch || disposed) throw loginRequired();
443
+ 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);
446
+ };
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();
451
+ if (message.recognition !== recognition) return void 0;
452
+ const current = await readCookieSessionVersion(scope.id);
453
+ if (!current?.active || current.jkt !== message.jkt || current.generation !== message.generation) return void 0;
454
+ const peer = readPeerToken(message.token, current.jkt, current.generation, now());
455
+ if (!peer) return void 0;
456
+ await existingKey(current.jkt);
457
+ const latest = await readCookieSessionVersion(scope.id);
458
+ if (epoch !== operationEpoch || disposed || !latest?.active || latest.generation !== current.generation)
459
+ return void 0;
460
+ token = peer;
461
+ tokenRecognition = recognition;
462
+ return peer;
463
+ };
464
+ const requestPeer = (jkt, generation, recognition, rejectedToken) => new Promise((resolve) => {
465
+ let settled = false;
466
+ const finish = (value) => {
467
+ if (settled) return;
468
+ settled = true;
469
+ clearTimeout(timeout);
470
+ peerWaiters.delete(waiter);
471
+ resolve(value);
472
+ };
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());
478
+ };
479
+ const timeout = setTimeout(() => finish(), 180);
480
+ peerWaiters.add(waiter);
481
+ channel?.postMessage({ type: "request-token", sender, jkt, generation, recognition });
482
+ });
483
+ if (channel)
484
+ channel.onmessage = (event) => {
485
+ 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 ?? ""))
487
+ return;
488
+ const captured = message;
489
+ if (captured.type === "request-token") {
490
+ const current = token;
491
+ if (current?.jkt === captured.jkt && current.generation === captured.generation && current.expiresAt > now() && tokenRecognition === captured.recognition) {
492
+ channel.postMessage({
493
+ type: "token",
494
+ sender,
495
+ jkt: current.jkt,
496
+ generation: current.generation,
497
+ token: current,
498
+ recognition: tokenRecognition
499
+ });
500
+ }
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));
506
+ } else {
507
+ for (const waiter of peerWaiters) waiter(captured);
508
+ void adoptPeer(captured).catch(() => clearLocal(false));
509
+ }
510
+ };
511
+ const onFailure = async (error) => {
512
+ if (!disposed && error instanceof OidcVaultDpopClientError && error.requiresLogin) await invalidate();
513
+ throw error;
514
+ };
515
+ const operation = (run) => withCookieLock(async () => {
516
+ try {
517
+ return await run();
518
+ } catch (error) {
519
+ return onFailure(error);
520
+ }
521
+ });
522
+ const session = {
523
+ scopeId: scope.id,
524
+ sessionTransport,
525
+ async login(returnTo) {
526
+ assertLive();
527
+ await operation(async () => {
528
+ await invalidate();
529
+ const key = await getOrCreateDpopKey(scope);
530
+ store(pendingKey, { jkt: key.jkt });
531
+ const body = returnTo === void 0 ? {} : { returnTo };
532
+ const { value } = await post("login", body, key);
533
+ if (typeof value !== "object" || value === null || !("authorizationUrl" in value) || typeof value.authorizationUrl !== "string") {
534
+ throw new OidcVaultDpopClientError(
535
+ "INVALID_LOGIN_RESPONSE",
536
+ "The login initiation response is invalid.",
537
+ true
538
+ );
539
+ }
540
+ let url;
541
+ try {
542
+ url = new URL(value.authorizationUrl);
543
+ } catch {
544
+ throw new OidcVaultDpopClientError(
545
+ "INVALID_LOGIN_RESPONSE",
546
+ "The login initiation response is invalid.",
547
+ true
548
+ );
549
+ }
550
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
551
+ throw new OidcVaultDpopClientError(
552
+ "INVALID_LOGIN_RESPONSE",
553
+ "The login initiation response is invalid.",
554
+ true
555
+ );
556
+ }
557
+ navigate(url.href);
558
+ });
559
+ },
560
+ async exchange(code) {
561
+ assertLive();
562
+ return operation(async () => {
563
+ const pending = readStorage(pendingKey);
564
+ const key = await existingKey(pending?.jkt);
565
+ 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);
568
+ store(pendingKey);
569
+ return result;
570
+ });
571
+ },
572
+ refresh(refreshOptions = {}) {
573
+ assertLive();
574
+ 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
+ }
593
+ }
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();
607
+ refreshPromise = pending;
608
+ void pending.finally(() => {
609
+ if (refreshPromise === pending) refreshPromise = void 0;
610
+ }).catch(() => {
611
+ });
612
+ return pending;
613
+ },
614
+ async logout() {
615
+ assertLive();
616
+ await operation(async () => {
617
+ const key = await authenticatedKey();
618
+ const handle = sessionTransport === "body" ? readStorage(storageKey) : void 0;
619
+ if (sessionTransport === "body" && !handle?.sessionId) throw loginRequired();
620
+ const { value } = await post("logout", handle ? { sessionId: handle.sessionId } : {}, key);
621
+ if (typeof value !== "object" || value === null || !("loggedOut" in value) || value.loggedOut !== true) {
622
+ throw new OidcVaultDpopClientError("INVALID_LOGOUT_RESPONSE", "The logout response is invalid.");
623
+ }
624
+ await invalidate();
625
+ });
626
+ },
627
+ getAccessToken: () => token,
628
+ getKey: () => operation(authenticatedKey),
629
+ clear: () => withCookieLock(invalidate),
630
+ dispose() {
631
+ disposed = true;
632
+ epoch++;
633
+ token = void 0;
634
+ tokenRecognition = void 0;
635
+ channel?.close();
636
+ peerWaiters.clear();
637
+ }
638
+ };
639
+ return Object.freeze(session);
640
+ };
641
+
642
+ // src/auth-fetch.ts
643
+ var defaultNonces = /* @__PURE__ */ new WeakMap();
644
+ var nonceCache = (context) => {
645
+ if (context.nonces) return context.nonces;
646
+ let cache = defaultNonces.get(context.session);
647
+ if (!cache) {
648
+ cache = new DpopNonceCache();
649
+ defaultNonces.set(context.session, cache);
650
+ }
651
+ return cache;
652
+ };
653
+ var replayableBody = (body) => {
654
+ if (body === void 0 || body === null || typeof body === "string") return () => body;
655
+ if (body instanceof URLSearchParams) {
656
+ const snapshot = body.toString();
657
+ return () => new URLSearchParams(snapshot);
658
+ }
659
+ if (body instanceof Blob) {
660
+ const snapshot = body.slice();
661
+ return () => snapshot.slice();
662
+ }
663
+ if (body instanceof ArrayBuffer) {
664
+ const snapshot = body.slice(0);
665
+ return () => snapshot.slice(0);
666
+ }
667
+ if (ArrayBuffer.isView(body)) {
668
+ const snapshot = new Uint8Array(body.buffer, body.byteOffset, body.byteLength).slice();
669
+ return () => snapshot.slice();
670
+ }
671
+ if (body instanceof FormData) {
672
+ const entries = [...body.entries()];
673
+ return () => {
674
+ const copy = new FormData();
675
+ for (const [name, value] of entries) copy.append(name, value);
676
+ return copy;
677
+ };
678
+ }
679
+ throw new OidcVaultDpopClientError("DPOP_BODY_NOT_REPLAYABLE", "Retry requires a replayable request body.");
680
+ };
681
+ var challengeError = (response) => response.status === 401 ? /(?:^|,\s*)DPoP\s+error="([a-z_]+)"(?:,|$)/i.exec(response.headers.get("WWW-Authenticate") ?? "")?.[1] : void 0;
682
+ 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
+ }
689
+ const { session, fetch: request = globalThis.fetch.bind(globalThis), now = Date.now } = context;
690
+ const { retry, ...init } = options;
691
+ const method = (init.method ?? "GET").toUpperCase();
692
+ if (retry !== void 0 && retry !== "never" && retry !== "idempotent")
693
+ throw new TypeError("Unknown DPoP retry policy.");
694
+ const canRetry = retry !== "never" && (["GET", "HEAD", "OPTIONS"].includes(method) || retry === "idempotent");
695
+ const body = canRetry ? replayableBody(init.body) : () => init.body;
696
+ const headers = new Headers(init.headers);
697
+ for (const name of ["Authorization", "DPoP", "Cookie", "Proxy-Authorization"]) {
698
+ if (headers.has(name))
699
+ throw new OidcVaultDpopClientError("DPOP_AUTH_HEADER_OVERRIDE", "The DPoP helper owns authentication headers.");
700
+ }
701
+ const space = JSON.stringify(["api", url.origin, api.replayNamespace]);
702
+ const nonces = nonceCache(context);
703
+ let nonceRetried = false;
704
+ let refreshed = false;
705
+ let credential = session.getAccessToken();
706
+ if (!credential || credential.expiresAt <= now()) {
707
+ credential = await session.refresh();
708
+ refreshed = true;
709
+ }
710
+ for (; ; ) {
711
+ if (options.signal?.aborted) throw options.signal.reason;
712
+ if (credential.tokenType !== "DPoP") {
713
+ await session.clear();
714
+ throw new OidcVaultDpopClientError(
715
+ "DPOP_TOKEN_TYPE_REQUIRED",
716
+ "A DPoP access token is required. Sign in again.",
717
+ true
718
+ );
719
+ }
720
+ const key = await session.getKey();
721
+ if (key.jkt !== credential.jkt) {
722
+ await session.clear();
723
+ throw loginRequired();
724
+ }
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
+ });
732
+ const attemptHeaders = new Headers(headers);
733
+ attemptHeaders.set("Authorization", `DPoP ${credential.accessToken}`);
734
+ 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"))) {
745
+ nonceRetried = true;
746
+ await response.body?.cancel();
747
+ continue;
748
+ }
749
+ if (challengeError(response) === "invalid_token" && canRetry && !refreshed) {
750
+ refreshed = true;
751
+ await response.body?.cancel();
752
+ credential = await session.refresh({ rejectedToken: credential.accessToken });
753
+ continue;
754
+ }
755
+ return response;
756
+ }
757
+ };
758
+
759
+ // src/device-fingerprint.ts
760
+ var COLLIDING_HEADERS = /* @__PURE__ */ new Set([
761
+ "authorization",
762
+ "proxy-authorization",
763
+ "www-authenticate",
764
+ "proxy-authenticate",
765
+ "authentication-info",
766
+ "proxy-authentication-info",
767
+ "dpop",
768
+ "dpop-nonce",
769
+ "cookie",
770
+ "set-cookie",
771
+ "origin",
772
+ "referer",
773
+ "host",
774
+ "forwarded",
775
+ "accept",
776
+ "accept-charset",
777
+ "accept-encoding",
778
+ "accept-language",
779
+ "connection",
780
+ "keep-alive",
781
+ "te",
782
+ "trailer",
783
+ "transfer-encoding",
784
+ "upgrade"
785
+ ]);
786
+ var isPrintableAscii = (value) => {
787
+ for (let index = 0; index < value.length; index++) {
788
+ const byte = value.charCodeAt(index);
789
+ if (byte < 32 || byte > 126) return false;
790
+ }
791
+ return true;
792
+ };
793
+ var createDeviceFingerprint = (source, options = {}) => {
794
+ const { headerName: configuredHeaderName } = options;
795
+ const headerName = configuredHeaderName === void 0 ? "X-Device-Fingerprint" : configuredHeaderName;
796
+ if (typeof headerName !== "string" || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(headerName) || !isPrintableAscii(headerName)) {
797
+ throw new Error("Fingerprint header name is invalid.");
798
+ }
799
+ const normalized = headerName.toLowerCase();
800
+ if (COLLIDING_HEADERS.has(normalized) || ["content-", "x-forwarded-", "access-control-", "sec-fetch-"].some((prefix) => normalized.startsWith(prefix))) {
801
+ throw new Error("Fingerprint header name collides with a protocol header.");
802
+ }
803
+ return Object.freeze({
804
+ async headers() {
805
+ let signal;
806
+ try {
807
+ signal = await source();
808
+ } catch {
809
+ throw new Error("Browser recognition is unavailable.");
810
+ }
811
+ if (signal === void 0) return Object.freeze({});
812
+ if (typeof signal !== "string" || signal.length === 0 || signal.length > 256 || !isPrintableAscii(signal)) {
813
+ throw new Error("Fingerprint signal is invalid.");
814
+ }
815
+ return Object.freeze({ [headerName]: signal });
816
+ }
817
+ });
818
+ };
819
+ var fingerprintJsSignalSource = (load) => {
820
+ let pendingAgent;
821
+ return async () => {
822
+ const pending = pendingAgent ??= Promise.resolve().then(load);
823
+ let agent;
824
+ try {
825
+ agent = await pending;
826
+ } catch (error) {
827
+ if (pendingAgent === pending) pendingAgent = void 0;
828
+ throw error;
829
+ }
830
+ return (await agent.get()).visitorId;
831
+ };
832
+ };
833
+ export {
834
+ DpopNonceCache,
835
+ OidcVaultDpopClientError,
836
+ createDeviceFingerprint,
837
+ createDpopProof,
838
+ createOidcVaultDpopSession,
839
+ fetchWithDpop,
840
+ fingerprintJsSignalSource,
841
+ getOrCreateDpopKey
842
+ };