@web-ts-toolkit/oidc-vault-dpop-client 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +483 -73
- package/index.d.mts +182 -81
- package/index.d.ts +182 -81
- package/index.js +943 -226
- package/index.mjs +946 -229
- package/llms.txt +52 -37
- package/package.json +2 -2
package/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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 &&
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
62
|
-
|
|
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}/`)
|
|
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
|
|
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) =>
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
221
|
-
|
|
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 (
|
|
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 =
|
|
233
|
-
if (!
|
|
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
|
-
|
|
436
|
+
return Object.freeze({
|
|
240
437
|
accessToken,
|
|
241
|
-
tokenType,
|
|
242
|
-
expiresAt
|
|
438
|
+
tokenType: "DPoP",
|
|
439
|
+
expiresAt,
|
|
243
440
|
jkt,
|
|
244
|
-
generation
|
|
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)
|
|
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 {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
);
|
|
266
|
-
|
|
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 (
|
|
282
|
-
|
|
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
|
|
314
|
-
|
|
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
|
-
|
|
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
|
|
350
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
clearLocal();
|
|
709
|
+
const invalidateCookie = async (authority, oldJkt, expected) => {
|
|
710
|
+
authority.assertCurrent();
|
|
356
711
|
if (channel) {
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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) =>
|
|
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 () =>
|
|
389
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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,
|
|
423
|
-
await existingKey(next.jkt);
|
|
424
|
-
|
|
830
|
+
const publish = async (next, recognition, authority, sessionId) => {
|
|
831
|
+
await existingKey(authority, next.jkt);
|
|
832
|
+
authority.assertCurrent();
|
|
425
833
|
if (channel) {
|
|
426
|
-
|
|
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
|
-
|
|
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,
|
|
442
|
-
|
|
855
|
+
const acceptResponse = async (response, key, authority) => {
|
|
856
|
+
authority.assertCurrent();
|
|
443
857
|
const parsed = parseDpopCredentials(response.value, sessionTransport, key.jkt, now());
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
448
|
-
if (
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
|
567
|
-
|
|
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
|
-
|
|
1085
|
+
const authority = lifecycle.capture();
|
|
1086
|
+
resetPromise = void 0;
|
|
574
1087
|
if (refreshPromise) return refreshPromise;
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
1149
|
+
clearLocal();
|
|
1150
|
+
await invalidateCookie(authority, key.jkt);
|
|
625
1151
|
});
|
|
626
1152
|
},
|
|
627
1153
|
getAccessToken: () => token,
|
|
628
|
-
getKey
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
|
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
|
|
684
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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 ${
|
|
1440
|
+
attemptHeaders.set("Authorization", `DPoP ${accessToken}`);
|
|
734
1441
|
attemptHeaders.set("DPoP", proof);
|
|
735
|
-
const response = await
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
1463
|
+
cancelResponse(response);
|
|
747
1464
|
continue;
|
|
748
1465
|
}
|
|
749
|
-
if (
|
|
1466
|
+
if (challenge === "invalid_token" && canRetry && !refreshed) {
|
|
750
1467
|
refreshed = true;
|
|
751
|
-
|
|
752
|
-
credential = await session.refresh({ rejectedToken:
|
|
1468
|
+
cancelResponse(response);
|
|
1469
|
+
credential = await abortable(signal, () => session.refresh({ rejectedToken: accessToken }));
|
|
753
1470
|
continue;
|
|
754
1471
|
}
|
|
755
1472
|
return response;
|