@eminuckan/spine 0.3.10 → 0.3.12
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/CHANGELOG.md +87 -0
- package/README.md +10 -6
- package/dist/api-client/fetch-client.server.d.ts.map +1 -1
- package/dist/api-client/server.mjs +1 -1
- package/dist/auth/auth.server.d.ts +4 -14
- package/dist/auth/auth.server.d.ts.map +1 -1
- package/dist/auth/oauth-recovery.server.d.ts +5 -11
- package/dist/auth/oauth-recovery.server.d.ts.map +1 -1
- package/dist/auth/oauth-security.d.ts.map +1 -1
- package/dist/auth/oauth-transaction-cookies.server.d.ts +18 -0
- package/dist/auth/oauth-transaction-cookies.server.d.ts.map +1 -0
- package/dist/auth/redis-session-storage.server.d.ts +24 -9
- package/dist/auth/redis-session-storage.server.d.ts.map +1 -1
- package/dist/auth/route-protection.server.d.ts.map +1 -1
- package/dist/auth/server.d.ts +1 -1
- package/dist/auth/server.d.ts.map +1 -1
- package/dist/auth/server.mjs +3 -3
- package/dist/auth/token-expiry.d.ts +6 -0
- package/dist/auth/token-expiry.d.ts.map +1 -0
- package/dist/auth/token-refresh.server.d.ts.map +1 -1
- package/dist/auth/types.d.ts +2 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/react-router/server.mjs +4 -4
- package/dist/{redis-session-storage.server-DLJ0B7-C.mjs → redis-session-storage.server--i4vhYxs.mjs} +119 -25
- package/dist/redis-session-storage.server--i4vhYxs.mjs.map +1 -0
- package/dist/{server-BJTz5-BE.mjs → server-W3MUOcah.mjs} +361 -238
- package/dist/server-W3MUOcah.mjs.map +1 -0
- package/dist/{server-pnsLybIH.mjs → server-iCmy3RW8.mjs} +11 -4
- package/dist/{server-pnsLybIH.mjs.map → server-iCmy3RW8.mjs.map} +1 -1
- package/dist/server.mjs +4 -4
- package/dist/tanstack-start/server.mjs +4 -4
- package/dist/tenant/server.mjs +3 -2
- package/dist/tenant/server.mjs.map +1 -1
- package/dist/tenant/tenant-cookie.server.d.ts.map +1 -1
- package/docs/adapters.md +9 -6
- package/docs/module-reference.md +1 -0
- package/package.json +1 -1
- package/dist/redis-session-storage.server-DLJ0B7-C.mjs.map +0 -1
- package/dist/server-BJTz5-BE.mjs.map +0 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { logger } from "./logging/index.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { A as saveRefreshedAuthSession, C as invalidateAuthSessionRefreshGeneration, F as OAUTH_STATE_TTL_MS, I as OAUTH_STATE_TTL_SECONDS, M as getTokenExpiry, N as OAUTH_RECOVERY_TTL_MS, O as releaseAuthSessionRefreshLock, P as OAUTH_RECOVERY_TTL_SECONDS, S as hasSameAuthTokenGeneration, T as listAuthSessionDataForUser, _ as destroyAuthSessionsForUser, c as createOAuthRecoveryRecord, d as deleteOAuthState, f as destroyAuthSession, g as destroyAuthSessionsBySid, i as clearAuthSessionCookie, l as createOAuthState, m as destroyAuthSessionsByIdentitySession, n as acquireAuthSessionRefreshLock, o as consumeOAuthRecoveryRecord, s as createAuthSession, t as AUTH_ERROR_COOKIE_PREFIX, u as deleteOAuthRecoveryRecord, v as getAuthSession, x as getOAuthState } from "./redis-session-storage.server--i4vhYxs.mjs";
|
|
3
|
+
import { env } from "node:process";
|
|
3
4
|
import * as oidc from "openid-client";
|
|
4
5
|
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
5
7
|
//#region src/http/response.ts
|
|
6
8
|
/**
|
|
7
9
|
* Create a redirect response without relying on a router/framework helper.
|
|
@@ -128,7 +130,7 @@ function serializeTemporaryAuthCookie(name, value, options) {
|
|
|
128
130
|
"SameSite=Lax",
|
|
129
131
|
`Max-Age=${options.maxAge}`
|
|
130
132
|
];
|
|
131
|
-
if (
|
|
133
|
+
if (env.NODE_ENV === "production") segments.push("Secure");
|
|
132
134
|
return segments.join("; ");
|
|
133
135
|
}
|
|
134
136
|
//#endregion
|
|
@@ -201,6 +203,84 @@ function isRecoverableOAuthCallbackError(error) {
|
|
|
201
203
|
return isOAuthCallbackError(error) && error.code === "stale_transaction";
|
|
202
204
|
}
|
|
203
205
|
//#endregion
|
|
206
|
+
//#region src/auth/oauth-transaction-cookies.server.ts
|
|
207
|
+
const OAUTH_STATE_COOKIE_NAME = `${AUTH_ERROR_COOKIE_PREFIX}_oauth_state_id`;
|
|
208
|
+
const OAUTH_RECOVERY_COOKIE_NAME = `${AUTH_ERROR_COOKIE_PREFIX}_oauth_recovery`;
|
|
209
|
+
const MAX_COOKIE_HEADER_LENGTH = 32 * 1024;
|
|
210
|
+
const MAX_COOKIE_PARTS = 256;
|
|
211
|
+
const MAX_COOKIE_NAME_LENGTH = 256;
|
|
212
|
+
const MAX_COOKIE_VALUE_LENGTH = 768;
|
|
213
|
+
const MAX_CALLBACK_STATE_LENGTH = 4096;
|
|
214
|
+
/** Hash the exact wire state so public context never becomes a cookie name. */
|
|
215
|
+
function getScopedOAuthCookieName(baseName, state) {
|
|
216
|
+
if (!isUsableCallbackState(state) || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(baseName) || baseName.length + 65 > MAX_COOKIE_NAME_LENGTH) throw new Error("Invalid OAuth transaction cookie scope");
|
|
217
|
+
return `${baseName}_${createHash("sha256").update(state, "utf8").digest("hex")}`;
|
|
218
|
+
}
|
|
219
|
+
function isUsableCallbackState(state) {
|
|
220
|
+
return state !== null && state.trim().length > 0 && state.length <= MAX_CALLBACK_STATE_LENGTH;
|
|
221
|
+
}
|
|
222
|
+
function getOAuthCallbackState(request) {
|
|
223
|
+
const states = new URL(request.url).searchParams.getAll("state");
|
|
224
|
+
return states.length === 1 && isUsableCallbackState(states[0]) ? states[0] : null;
|
|
225
|
+
}
|
|
226
|
+
function parseCookies(request) {
|
|
227
|
+
const header = request.headers.get("Cookie") ?? "";
|
|
228
|
+
if (header.length > MAX_COOKIE_HEADER_LENGTH) return null;
|
|
229
|
+
const parts = header.split(";");
|
|
230
|
+
if (parts.length > MAX_COOKIE_PARTS) return null;
|
|
231
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
232
|
+
for (const part of parts) {
|
|
233
|
+
const separator = part.indexOf("=");
|
|
234
|
+
if (separator < 0) continue;
|
|
235
|
+
const name = part.slice(0, separator).trim();
|
|
236
|
+
if (name.length > MAX_COOKIE_NAME_LENGTH) return null;
|
|
237
|
+
const values = cookies.get(name) ?? [];
|
|
238
|
+
values.push(part.slice(separator + 1).trim());
|
|
239
|
+
cookies.set(name, values);
|
|
240
|
+
}
|
|
241
|
+
return cookies;
|
|
242
|
+
}
|
|
243
|
+
/** A present but invalid scoped cookie must never fall back to the singleton. */
|
|
244
|
+
function readOAuthTransactionCookie(request, baseName, state, allowLegacy = true) {
|
|
245
|
+
if (!isUsableCallbackState(state)) return {
|
|
246
|
+
name: null,
|
|
247
|
+
value: null,
|
|
248
|
+
present: false,
|
|
249
|
+
malformed: false,
|
|
250
|
+
scoped: false
|
|
251
|
+
};
|
|
252
|
+
const scopedName = getScopedOAuthCookieName(baseName, state);
|
|
253
|
+
const cookies = parseCookies(request);
|
|
254
|
+
if (!cookies) return {
|
|
255
|
+
name: scopedName,
|
|
256
|
+
value: null,
|
|
257
|
+
present: false,
|
|
258
|
+
malformed: true,
|
|
259
|
+
scoped: true
|
|
260
|
+
};
|
|
261
|
+
const name = cookies.has(scopedName) || !allowLegacy || !cookies.has(baseName) ? scopedName : baseName;
|
|
262
|
+
const values = cookies.get(name);
|
|
263
|
+
let value = null;
|
|
264
|
+
if (values?.length === 1 && values[0].length <= MAX_COOKIE_VALUE_LENGTH) try {
|
|
265
|
+
const decoded = decodeURIComponent(values[0]);
|
|
266
|
+
if (/^[A-Za-z0-9_-]{1,256}$/.test(decoded)) value = decoded;
|
|
267
|
+
} catch {}
|
|
268
|
+
return {
|
|
269
|
+
name,
|
|
270
|
+
value,
|
|
271
|
+
present: values !== void 0,
|
|
272
|
+
malformed: values !== void 0 && value === null,
|
|
273
|
+
scoped: name === scopedName
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/** Bound browser growth without evicting another tab's pending transaction. */
|
|
277
|
+
function assertOAuthTransactionCapacity(request) {
|
|
278
|
+
const cookies = parseCookies(request);
|
|
279
|
+
if (!cookies) throw new Error("Invalid OAuth cookie header");
|
|
280
|
+
const prefix = `${OAUTH_STATE_COOKIE_NAME}_`;
|
|
281
|
+
if (Array.from(cookies.keys()).filter((name) => name.startsWith(prefix) && /^[a-f0-9]{64}$/.test(name.slice(prefix.length))).length >= 8) throw new Error("Too many pending OAuth transactions");
|
|
282
|
+
}
|
|
283
|
+
//#endregion
|
|
204
284
|
//#region src/auth/oauth-recovery.server.ts
|
|
205
285
|
/**
|
|
206
286
|
* Server-only recovery intent for stale OAuth callbacks.
|
|
@@ -209,30 +289,9 @@ function isRecoverableOAuthCallbackError(error) {
|
|
|
209
289
|
* record. Consumers must atomically consume it with the typed stale callback
|
|
210
290
|
* error; the client never supplies a boolean budget or a return URL.
|
|
211
291
|
*/
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
function getCookieValue(request) {
|
|
217
|
-
const cookies = request.headers.get("Cookie");
|
|
218
|
-
if (!cookies) return null;
|
|
219
|
-
const cookieRegex = new RegExp(`(?:^|;\\s*)${escapeRegExp$1(OAUTH_RECOVERY_COOKIE_NAME)}=([^;]*)`);
|
|
220
|
-
const rawValue = cookies.match(cookieRegex)?.[1];
|
|
221
|
-
if (!rawValue) return null;
|
|
222
|
-
try {
|
|
223
|
-
const value = decodeURIComponent(rawValue);
|
|
224
|
-
return value.length > 0 ? value : null;
|
|
225
|
-
} catch {
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
function hasCookie(request) {
|
|
230
|
-
const cookies = request.headers.get("Cookie");
|
|
231
|
-
if (!cookies) return false;
|
|
232
|
-
return cookies.split(";").some((part) => {
|
|
233
|
-
const separator = part.indexOf("=");
|
|
234
|
-
return separator > 0 && part.slice(0, separator).trim() === OAUTH_RECOVERY_COOKIE_NAME;
|
|
235
|
-
});
|
|
292
|
+
function getRecoveryCookie(request, state = getOAuthCallbackState(request)) {
|
|
293
|
+
const stateCookie = readOAuthTransactionCookie(request, OAUTH_STATE_COOKIE_NAME, state);
|
|
294
|
+
return readOAuthTransactionCookie(request, OAUTH_RECOVERY_COOKIE_NAME, state, !(stateCookie.present && stateCookie.scoped));
|
|
236
295
|
}
|
|
237
296
|
const SAFE_FALLBACK_COOKIE_PATH = "/";
|
|
238
297
|
function normalizeCookiePath(pathname) {
|
|
@@ -256,19 +315,20 @@ function getOAuthRecoveryCookiePath(source) {
|
|
|
256
315
|
return SAFE_FALLBACK_COOKIE_PATH;
|
|
257
316
|
}
|
|
258
317
|
}
|
|
259
|
-
function serializeRecoveryCookie(ticket, maxAge, callbackPath) {
|
|
318
|
+
function serializeRecoveryCookie(ticket, maxAge, callbackPath, cookieName = OAUTH_RECOVERY_COOKIE_NAME) {
|
|
260
319
|
const segments = [
|
|
261
|
-
`${
|
|
320
|
+
`${cookieName}=${encodeURIComponent(ticket)}`,
|
|
262
321
|
`Path=${callbackPath}`,
|
|
263
322
|
"HttpOnly",
|
|
264
323
|
"SameSite=Lax",
|
|
265
324
|
`Max-Age=${maxAge}`
|
|
266
325
|
];
|
|
267
|
-
if (
|
|
326
|
+
if (env.NODE_ENV === "production") segments.push("Secure");
|
|
268
327
|
return segments.join("; ");
|
|
269
328
|
}
|
|
270
|
-
function appendRecoveryCookieClear(headers, callbackPath) {
|
|
271
|
-
|
|
329
|
+
function appendRecoveryCookieClear(headers, callbackPath, cookieName = OAUTH_RECOVERY_COOKIE_NAME) {
|
|
330
|
+
if (!cookieName) return;
|
|
331
|
+
headers.append("Set-Cookie", serializeRecoveryCookie("", 0, callbackPath, cookieName));
|
|
272
332
|
}
|
|
273
333
|
function getSetCookieValues(headers) {
|
|
274
334
|
const getSetCookie = headers.getSetCookie;
|
|
@@ -279,9 +339,11 @@ function getSetCookieValues(headers) {
|
|
|
279
339
|
* Build a lossless ticket-clear header using a callback request, URL, or
|
|
280
340
|
* configured redirect URI as the cookie-path source.
|
|
281
341
|
*/
|
|
282
|
-
function clearOAuthRecoveryIntentHeaders(source) {
|
|
342
|
+
function clearOAuthRecoveryIntentHeaders(source, state) {
|
|
283
343
|
const headers = new Headers();
|
|
284
|
-
|
|
344
|
+
const requestCookie = source instanceof Request ? getRecoveryCookie(source) : null;
|
|
345
|
+
const cookieName = state !== void 0 ? getScopedOAuthCookieName(OAUTH_RECOVERY_COOKIE_NAME, state) : requestCookie ? requestCookie.scoped ? requestCookie.name : null : OAUTH_RECOVERY_COOKIE_NAME;
|
|
346
|
+
appendRecoveryCookieClear(headers, getOAuthRecoveryCookiePath(source), cookieName);
|
|
285
347
|
return headers;
|
|
286
348
|
}
|
|
287
349
|
function hasNonEmptyParam(url, name) {
|
|
@@ -294,25 +356,23 @@ function hasNonEmptyParam(url, name) {
|
|
|
294
356
|
*/
|
|
295
357
|
function isOAuthRecoveryEligibleCallback(request) {
|
|
296
358
|
const url = new URL(request.url);
|
|
297
|
-
|
|
359
|
+
const state = getOAuthCallbackState(request);
|
|
360
|
+
return hasNonEmptyParam(url, "code") && state !== null && !readOAuthTransactionCookie(request, OAUTH_STATE_COOKIE_NAME, state).malformed && !url.searchParams.has("error") && !url.searchParams.has("kc_action") && !url.searchParams.has("kc_action_status");
|
|
298
361
|
}
|
|
299
362
|
function isRecoveryRecord(value) {
|
|
300
363
|
if (value === null || typeof value !== "object") return false;
|
|
301
364
|
const record = value;
|
|
302
365
|
return typeof record.state === "string" && record.state.length > 0 && typeof record.returnUrl === "string" && typeof record.createdAt === "number" && Number.isFinite(record.createdAt);
|
|
303
366
|
}
|
|
304
|
-
function getCallbackState(request) {
|
|
305
|
-
const value = new URL(request.url).searchParams.get("state");
|
|
306
|
-
return value && value.trim().length > 0 ? value : null;
|
|
307
|
-
}
|
|
308
367
|
/**
|
|
309
368
|
* Create the opaque ticket cookie for an ordinary login transaction.
|
|
310
369
|
* Application-action transactions deliberately do not receive a recovery
|
|
311
370
|
* ticket and therefore cannot enter the automatic recovery path.
|
|
312
371
|
*/
|
|
313
|
-
async function createOAuthRecoveryForLogin(request, state, redirectUri) {
|
|
372
|
+
async function createOAuthRecoveryForLogin(request, state, redirectUri, cookieState) {
|
|
314
373
|
const returnUrl = sanitizeOAuthReturnUrl(state.returnUrl, request) ?? "/";
|
|
315
374
|
const callbackPath = getOAuthRecoveryCookiePath(redirectUri);
|
|
375
|
+
const cookieName = cookieState === void 0 ? OAUTH_RECOVERY_COOKIE_NAME : getScopedOAuthCookieName(OAUTH_RECOVERY_COOKIE_NAME, cookieState);
|
|
316
376
|
const ticket = await createOAuthRecoveryRecord({
|
|
317
377
|
state: state.state,
|
|
318
378
|
returnUrl,
|
|
@@ -320,7 +380,7 @@ async function createOAuthRecoveryForLogin(request, state, redirectUri) {
|
|
|
320
380
|
});
|
|
321
381
|
return {
|
|
322
382
|
ticket,
|
|
323
|
-
cookie: serializeRecoveryCookie(ticket, OAUTH_RECOVERY_TTL_SECONDS, callbackPath)
|
|
383
|
+
cookie: serializeRecoveryCookie(ticket, OAUTH_RECOVERY_TTL_SECONDS, callbackPath, cookieName)
|
|
324
384
|
};
|
|
325
385
|
}
|
|
326
386
|
/**
|
|
@@ -329,11 +389,14 @@ async function createOAuthRecoveryForLogin(request, state, redirectUri) {
|
|
|
329
389
|
* cookie if deletion fails.
|
|
330
390
|
*/
|
|
331
391
|
async function discardOAuthRecoveryForRequest(request) {
|
|
332
|
-
const
|
|
392
|
+
const state = getOAuthCallbackState(request);
|
|
393
|
+
const cookie = getRecoveryCookie(request, state);
|
|
333
394
|
const headers = new Headers();
|
|
334
|
-
if (!
|
|
335
|
-
if (
|
|
336
|
-
|
|
395
|
+
if (!cookie.present || !state) return headers;
|
|
396
|
+
if (cookie.value) {
|
|
397
|
+
if (!await consumeOAuthRecoveryRecord(cookie.value, state) && !cookie.scoped) return headers;
|
|
398
|
+
}
|
|
399
|
+
appendRecoveryCookieClear(headers, getOAuthRecoveryCookiePath(request), cookie.name);
|
|
337
400
|
return headers;
|
|
338
401
|
}
|
|
339
402
|
/**
|
|
@@ -345,53 +408,33 @@ async function discardOAuthRecoveryForRequest(request) {
|
|
|
345
408
|
* cannot retry.
|
|
346
409
|
*/
|
|
347
410
|
async function consumeOAuthRecoveryIntent(request, callbackError) {
|
|
348
|
-
const
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (!ticket || !callbackState) return {
|
|
358
|
-
intent: null,
|
|
359
|
-
cleanupHeaders,
|
|
360
|
-
cleanupSetCookies
|
|
411
|
+
const cookie = getRecoveryCookie(request);
|
|
412
|
+
const result = (intent = null, ownsLegacy = false) => {
|
|
413
|
+
const cleanupHeaders = new Headers();
|
|
414
|
+
appendRecoveryCookieClear(cleanupHeaders, getOAuthRecoveryCookiePath(request), cookie.scoped || ownsLegacy ? cookie.name : null);
|
|
415
|
+
return {
|
|
416
|
+
intent,
|
|
417
|
+
cleanupHeaders,
|
|
418
|
+
cleanupSetCookies: getSetCookieValues(cleanupHeaders)
|
|
419
|
+
};
|
|
361
420
|
};
|
|
421
|
+
if (!isRecoverableOAuthCallbackError(callbackError) || !isOAuthRecoveryEligibleCallback(request)) return result();
|
|
422
|
+
const ticket = cookie.value;
|
|
423
|
+
const callbackState = getOAuthCallbackState(request);
|
|
424
|
+
if (!ticket || !callbackState) return result();
|
|
362
425
|
let record;
|
|
363
426
|
try {
|
|
364
427
|
record = await consumeOAuthRecoveryRecord(ticket, callbackState);
|
|
365
428
|
} catch (error) {
|
|
366
429
|
logger.warn("OAuth recovery record could not be consumed", { errorType: error instanceof Error ? error.name : "UnknownError" });
|
|
367
|
-
return
|
|
368
|
-
intent: null,
|
|
369
|
-
cleanupHeaders,
|
|
370
|
-
cleanupSetCookies
|
|
371
|
-
};
|
|
430
|
+
return result();
|
|
372
431
|
}
|
|
373
|
-
if (!isRecoveryRecord(record)) return
|
|
374
|
-
intent: null,
|
|
375
|
-
cleanupHeaders,
|
|
376
|
-
cleanupSetCookies
|
|
377
|
-
};
|
|
432
|
+
if (!isRecoveryRecord(record) || record.state !== callbackState) return result();
|
|
378
433
|
const age = Date.now() - record.createdAt;
|
|
379
|
-
if (age < 0 || age > OAUTH_RECOVERY_TTL_MS) return
|
|
380
|
-
intent: null,
|
|
381
|
-
cleanupHeaders,
|
|
382
|
-
cleanupSetCookies
|
|
383
|
-
};
|
|
434
|
+
if (age < 0 || age > OAUTH_RECOVERY_TTL_MS) return result(null, true);
|
|
384
435
|
const returnUrl = sanitizeOAuthReturnUrl(record.returnUrl, request);
|
|
385
|
-
if (!returnUrl) return
|
|
386
|
-
|
|
387
|
-
cleanupHeaders,
|
|
388
|
-
cleanupSetCookies
|
|
389
|
-
};
|
|
390
|
-
return {
|
|
391
|
-
intent: { returnUrl },
|
|
392
|
-
cleanupHeaders,
|
|
393
|
-
cleanupSetCookies
|
|
394
|
-
};
|
|
436
|
+
if (!returnUrl) return result(null, true);
|
|
437
|
+
return result({ returnUrl }, true);
|
|
395
438
|
}
|
|
396
439
|
//#endregion
|
|
397
440
|
//#region src/auth/auth.server.ts
|
|
@@ -547,7 +590,7 @@ function isLocalInsecureIssuer(authority) {
|
|
|
547
590
|
}
|
|
548
591
|
}
|
|
549
592
|
function shouldAllowInsecureOidcRequests(authority) {
|
|
550
|
-
if (
|
|
593
|
+
if (env.NODE_ENV === "production") return false;
|
|
551
594
|
if (process.env.OIDC_ALLOW_INSECURE_REQUESTS === "true") return true;
|
|
552
595
|
return isLocalInsecureIssuer(authority);
|
|
553
596
|
}
|
|
@@ -870,7 +913,6 @@ async function createSessionData(tokenResult, claims) {
|
|
|
870
913
|
}
|
|
871
914
|
const AUTH_ERROR_COOKIE = `${AUTH_ERROR_COOKIE_PREFIX}_auth_error`;
|
|
872
915
|
const AUTH_ERROR_DESC_COOKIE = `${AUTH_ERROR_COOKIE_PREFIX}_auth_error_desc`;
|
|
873
|
-
const OAUTH_STATE_COOKIE = `${AUTH_ERROR_COOKIE_PREFIX}_oauth_state_id`;
|
|
874
916
|
const LOGOUT_RETURN_URL_COOKIE = "logout_return_url";
|
|
875
917
|
/**
|
|
876
918
|
* Check if there's an auth error in cookies
|
|
@@ -924,22 +966,6 @@ function clearAuthErrorHeaders() {
|
|
|
924
966
|
function hasAuthError(request) {
|
|
925
967
|
return request.headers.get("Cookie")?.includes(`${AUTH_ERROR_COOKIE}=`) ?? false;
|
|
926
968
|
}
|
|
927
|
-
function escapeRegExp(value) {
|
|
928
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
929
|
-
}
|
|
930
|
-
function getOAuthStateIdFromRequest(request) {
|
|
931
|
-
const cookies = request.headers.get("Cookie");
|
|
932
|
-
if (!cookies) return null;
|
|
933
|
-
const cookieRegex = new RegExp(`(?:^|;\\s*)${escapeRegExp(OAUTH_STATE_COOKIE)}=([^;]*)`);
|
|
934
|
-
const cookieValue = cookies.match(cookieRegex)?.[1];
|
|
935
|
-
if (!cookieValue) return null;
|
|
936
|
-
try {
|
|
937
|
-
const stateId = decodeURIComponent(cookieValue);
|
|
938
|
-
return stateId.length > 0 ? stateId : null;
|
|
939
|
-
} catch {
|
|
940
|
-
return null;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
969
|
function hasNonEmptyCallbackValue(value) {
|
|
944
970
|
return value !== null && value.trim().length > 0;
|
|
945
971
|
}
|
|
@@ -953,8 +979,9 @@ function appendHeadersPreservingSetCookie(target, source) {
|
|
|
953
979
|
}
|
|
954
980
|
for (const [name, value] of sourceHeaders) if (name.toLowerCase() !== "set-cookie") target.set(name, value);
|
|
955
981
|
}
|
|
956
|
-
function appendOAuthStateCookieClear(headers) {
|
|
957
|
-
|
|
982
|
+
function appendOAuthStateCookieClear(headers, cookieName) {
|
|
983
|
+
if (!cookieName) return;
|
|
984
|
+
headers.append("Set-Cookie", serializeTemporaryAuthCookie(cookieName, "", { maxAge: 0 }));
|
|
958
985
|
}
|
|
959
986
|
function isUsableOAuthState(state, requireNonce) {
|
|
960
987
|
return state !== null && typeof state.state === "string" && state.state.length > 0 && typeof state.codeVerifier === "string" && state.codeVerifier.length > 0 && (state.nonce === void 0 ? !requireNonce : typeof state.nonce === "string" && state.nonce.length > 0) && typeof state.createdAt === "number" && Number.isFinite(state.createdAt) && (state.returnUrl === void 0 || typeof state.returnUrl === "string") && (state.kcAction === void 0 || typeof state.kcAction === "string");
|
|
@@ -971,12 +998,12 @@ function getCallbackFailureCode(error, stage) {
|
|
|
971
998
|
default: return OAuthCallbackFailureCodes.StorageError;
|
|
972
999
|
}
|
|
973
1000
|
}
|
|
974
|
-
async function createOAuthCallbackFailure(request, stateId, failureCode) {
|
|
1001
|
+
async function createOAuthCallbackFailure(request, stateId, failureCode, callbackState, stateCookie, canClearStateCookie) {
|
|
975
1002
|
const cleanupHeaders = new Headers();
|
|
976
1003
|
let cleanupError;
|
|
977
1004
|
const preserveRecoveryTicket = failureCode === OAuthCallbackFailureCodes.StaleTransaction && isOAuthRecoveryEligibleCallback(request);
|
|
978
|
-
if (stateId) try {
|
|
979
|
-
await deleteOAuthState(stateId);
|
|
1005
|
+
if (stateId && callbackState) try {
|
|
1006
|
+
await deleteOAuthState(stateId, callbackState);
|
|
980
1007
|
} catch (error) {
|
|
981
1008
|
cleanupError = error;
|
|
982
1009
|
}
|
|
@@ -991,7 +1018,7 @@ async function createOAuthCallbackFailure(request, stateId, failureCode) {
|
|
|
991
1018
|
cleanupError ??= error;
|
|
992
1019
|
appendHeadersPreservingSetCookie(cleanupHeaders, clearOAuthRecoveryIntentHeaders(request));
|
|
993
1020
|
}
|
|
994
|
-
appendOAuthStateCookieClear(cleanupHeaders);
|
|
1021
|
+
appendOAuthStateCookieClear(cleanupHeaders, canClearStateCookie ? stateCookie.name : null);
|
|
995
1022
|
const effectiveFailureCode = cleanupError ? OAuthCallbackFailureCodes.StorageError : failureCode;
|
|
996
1023
|
if (cleanupError) logger.warn("OAuth callback cleanup failed", {
|
|
997
1024
|
failureCode: effectiveFailureCode,
|
|
@@ -1007,6 +1034,7 @@ async function createOAuthCallbackFailure(request, stateId, failureCode) {
|
|
|
1007
1034
|
*/
|
|
1008
1035
|
async function login(request, returnUrlOrOptions) {
|
|
1009
1036
|
let oauthStateId = null;
|
|
1037
|
+
let oauthStateValue = null;
|
|
1010
1038
|
let recoveryTicket = null;
|
|
1011
1039
|
let recoveryCookie = null;
|
|
1012
1040
|
try {
|
|
@@ -1020,11 +1048,14 @@ async function login(request, returnUrlOrOptions) {
|
|
|
1020
1048
|
const baseUrl = new URL(config.redirectUri).origin;
|
|
1021
1049
|
return createRedirectResponse(baseUrl, { headers });
|
|
1022
1050
|
}
|
|
1051
|
+
assertOAuthTransactionCapacity(request);
|
|
1023
1052
|
const oidcConfig = await getOAuthConfig();
|
|
1024
1053
|
const codeVerifier = oidc.randomPKCECodeVerifier();
|
|
1025
1054
|
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
|
1026
1055
|
const extraAuthParams = sanitizeExtraAuthorizationParameters(options.extraAuthParams, options);
|
|
1027
1056
|
const state = buildAuthorizationState(oidc.randomState(), options.publicStateContext);
|
|
1057
|
+
oauthStateValue = state;
|
|
1058
|
+
const stateCookieName = getScopedOAuthCookieName(OAUTH_STATE_COOKIE_NAME, state);
|
|
1028
1059
|
const nonce = oidc.randomNonce();
|
|
1029
1060
|
const oauthState = {
|
|
1030
1061
|
state,
|
|
@@ -1045,7 +1076,7 @@ async function login(request, returnUrlOrOptions) {
|
|
|
1045
1076
|
const recovery = await createOAuthRecoveryForLogin(request, {
|
|
1046
1077
|
state,
|
|
1047
1078
|
returnUrl
|
|
1048
|
-
}, config.redirectUri);
|
|
1079
|
+
}, config.redirectUri, state);
|
|
1049
1080
|
recoveryTicket = recovery.ticket;
|
|
1050
1081
|
recoveryCookie = recovery.cookie;
|
|
1051
1082
|
}
|
|
@@ -1062,7 +1093,7 @@ async function login(request, returnUrlOrOptions) {
|
|
|
1062
1093
|
if (options.kcAction) authorizationParameters.kc_action = options.kcAction;
|
|
1063
1094
|
const authorizationUrl = oidc.buildAuthorizationUrl(oidcConfig, authorizationParameters);
|
|
1064
1095
|
const headers = new Headers();
|
|
1065
|
-
headers.append("Set-Cookie", serializeTemporaryAuthCookie(
|
|
1096
|
+
headers.append("Set-Cookie", serializeTemporaryAuthCookie(stateCookieName, oauthStateId, { maxAge: OAUTH_STATE_TTL_SECONDS }));
|
|
1066
1097
|
if (recoveryCookie) headers.append("Set-Cookie", recoveryCookie);
|
|
1067
1098
|
logger.info("OIDC authorization flow initiated", {
|
|
1068
1099
|
clientId: config.clientId,
|
|
@@ -1076,7 +1107,7 @@ async function login(request, returnUrlOrOptions) {
|
|
|
1076
1107
|
return createRedirectResponse(authorizationUrl.toString(), { headers });
|
|
1077
1108
|
} catch (error) {
|
|
1078
1109
|
if (oauthStateId) try {
|
|
1079
|
-
await deleteOAuthState(oauthStateId);
|
|
1110
|
+
await deleteOAuthState(oauthStateId, oauthStateValue ?? void 0);
|
|
1080
1111
|
} catch (cleanupError) {
|
|
1081
1112
|
logger.warn("OAuth login state cleanup failed", { errorType: cleanupError instanceof Error ? cleanupError.name : "UnknownError" });
|
|
1082
1113
|
}
|
|
@@ -1104,14 +1135,27 @@ async function login(request, returnUrlOrOptions) {
|
|
|
1104
1135
|
async function handleCallback(request) {
|
|
1105
1136
|
let stateId = null;
|
|
1106
1137
|
let callbackStage = "configuration";
|
|
1138
|
+
let callbackState = null;
|
|
1139
|
+
let canClearStateCookie = false;
|
|
1140
|
+
let stateCookie = {
|
|
1141
|
+
name: null,
|
|
1142
|
+
value: null,
|
|
1143
|
+
present: false,
|
|
1144
|
+
malformed: false,
|
|
1145
|
+
scoped: false
|
|
1146
|
+
};
|
|
1107
1147
|
try {
|
|
1108
|
-
|
|
1148
|
+
callbackState = getOAuthCallbackState(request);
|
|
1149
|
+
stateCookie = readOAuthTransactionCookie(request, OAUTH_STATE_COOKIE_NAME, callbackState);
|
|
1150
|
+
stateId = stateCookie.value;
|
|
1151
|
+
canClearStateCookie = stateCookie.scoped;
|
|
1152
|
+
if (stateCookie.malformed) throw new OAuthCallbackError(OAuthCallbackFailureCodes.MalformedCallback);
|
|
1109
1153
|
const config = getAuthConfig();
|
|
1110
1154
|
const hasLandingPage = getEffectiveHasLandingPage(config);
|
|
1111
1155
|
const requiresNonce = config.scope.split(/\s+/).includes("openid");
|
|
1112
1156
|
const url = new URL(request.url);
|
|
1113
1157
|
const code = url.searchParams.get("code");
|
|
1114
|
-
const state =
|
|
1158
|
+
const state = callbackState;
|
|
1115
1159
|
const error = url.searchParams.get("error");
|
|
1116
1160
|
const errorDescription = url.searchParams.get("error_description");
|
|
1117
1161
|
const kcAction = url.searchParams.get("kc_action");
|
|
@@ -1129,19 +1173,25 @@ async function handleCallback(request) {
|
|
|
1129
1173
|
clientId: config.clientId,
|
|
1130
1174
|
redirectUri: config.redirectUri
|
|
1131
1175
|
});
|
|
1176
|
+
if (error && stateId && !stateCookie.scoped) {
|
|
1177
|
+
callbackStage = "state";
|
|
1178
|
+
const pendingState = await getOAuthState(stateId, { throwOnMalformed: true });
|
|
1179
|
+
canClearStateCookie = !pendingState || pendingState.state === state;
|
|
1180
|
+
}
|
|
1132
1181
|
if (error) {
|
|
1133
1182
|
if (kcActionStatus && hasState) {
|
|
1134
1183
|
callbackStage = "state";
|
|
1135
1184
|
if (stateId) {
|
|
1136
1185
|
const oauthState = await getOAuthState(stateId, { throwOnMalformed: true });
|
|
1186
|
+
canClearStateCookie = stateCookie.scoped || !oauthState || oauthState.state === state;
|
|
1137
1187
|
const stateAge = oauthState ? Date.now() - oauthState.createdAt : Number.POSITIVE_INFINITY;
|
|
1138
1188
|
if (oauthState && isUsableOAuthState(oauthState, requiresNonce) && oauthState.state === state && stateAge <= OAUTH_STATE_TTL_MS) {
|
|
1139
1189
|
callbackStage = "storage";
|
|
1140
|
-
await deleteOAuthState(stateId);
|
|
1190
|
+
await deleteOAuthState(stateId, state);
|
|
1141
1191
|
const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
|
|
1142
1192
|
callbackStage = "application_action";
|
|
1143
1193
|
const headers = new Headers();
|
|
1144
|
-
appendOAuthStateCookieClear(headers);
|
|
1194
|
+
appendOAuthStateCookieClear(headers, canClearStateCookie ? stateCookie.name : null);
|
|
1145
1195
|
appendHeadersPreservingSetCookie(headers, recoveryHeaders);
|
|
1146
1196
|
const redirectUrl = appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus);
|
|
1147
1197
|
logger.info("Application initiated action returned with OAuth error", {
|
|
@@ -1164,9 +1214,9 @@ async function handleCallback(request) {
|
|
|
1164
1214
|
if (error === "access_denied") {
|
|
1165
1215
|
logger.warn("Access denied for this application", { clientId: config.clientId });
|
|
1166
1216
|
callbackStage = "storage";
|
|
1167
|
-
if (stateId) await deleteOAuthState(stateId);
|
|
1217
|
+
if (stateId) await deleteOAuthState(stateId, state);
|
|
1168
1218
|
const headers = await destroyAuthSession(request);
|
|
1169
|
-
appendOAuthStateCookieClear(headers);
|
|
1219
|
+
appendOAuthStateCookieClear(headers, canClearStateCookie ? stateCookie.name : null);
|
|
1170
1220
|
appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
|
|
1171
1221
|
headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_COOKIE, "access_denied", { maxAge: 300 }));
|
|
1172
1222
|
headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_DESC_COOKIE, "You do not have access to this application.", { maxAge: 300 }));
|
|
@@ -1181,17 +1231,17 @@ async function handleCallback(request) {
|
|
|
1181
1231
|
if (error === "login_required") {
|
|
1182
1232
|
logger.info("Login required, user session may have expired");
|
|
1183
1233
|
callbackStage = "storage";
|
|
1184
|
-
if (stateId) await deleteOAuthState(stateId);
|
|
1234
|
+
if (stateId) await deleteOAuthState(stateId, state);
|
|
1185
1235
|
const headers = await destroyAuthSession(request);
|
|
1186
|
-
appendOAuthStateCookieClear(headers);
|
|
1236
|
+
appendOAuthStateCookieClear(headers, canClearStateCookie ? stateCookie.name : null);
|
|
1187
1237
|
appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
|
|
1188
1238
|
const baseUrl = new URL(config.redirectUri).origin;
|
|
1189
1239
|
return createRedirectResponse(baseUrl, { headers });
|
|
1190
1240
|
}
|
|
1191
1241
|
callbackStage = "storage";
|
|
1192
|
-
if (stateId) await deleteOAuthState(stateId);
|
|
1242
|
+
if (stateId) await deleteOAuthState(stateId, state);
|
|
1193
1243
|
const headers = await destroyAuthSession(request);
|
|
1194
|
-
appendOAuthStateCookieClear(headers);
|
|
1244
|
+
appendOAuthStateCookieClear(headers, canClearStateCookie ? stateCookie.name : null);
|
|
1195
1245
|
appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
|
|
1196
1246
|
headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_COOKIE, error, { maxAge: 300 }));
|
|
1197
1247
|
const baseUrl = new URL(config.redirectUri).origin;
|
|
@@ -1211,6 +1261,7 @@ async function handleCallback(request) {
|
|
|
1211
1261
|
}
|
|
1212
1262
|
callbackStage = "state";
|
|
1213
1263
|
const oauthState = await getOAuthState(stateId, { throwOnMalformed: true });
|
|
1264
|
+
canClearStateCookie = stateCookie.scoped || !oauthState || oauthState.state === state;
|
|
1214
1265
|
if (!oauthState) throw new OAuthCallbackError(hasApplicationAction ? OAuthCallbackFailureCodes.ApplicationActionFailed : OAuthCallbackFailureCodes.StaleTransaction);
|
|
1215
1266
|
if (!isUsableOAuthState(oauthState, requiresNonce)) throw new OAuthCallbackError(OAuthCallbackFailureCodes.StorageError);
|
|
1216
1267
|
if (oauthState.state !== state) throw new OAuthCallbackError(OAuthCallbackFailureCodes.StateMismatch);
|
|
@@ -1219,11 +1270,11 @@ async function handleCallback(request) {
|
|
|
1219
1270
|
if (!hasCode) {
|
|
1220
1271
|
if (kcActionStatus) {
|
|
1221
1272
|
callbackStage = "storage";
|
|
1222
|
-
await deleteOAuthState(stateId);
|
|
1273
|
+
await deleteOAuthState(stateId, state);
|
|
1223
1274
|
const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
|
|
1224
1275
|
callbackStage = "application_action";
|
|
1225
1276
|
const headers = new Headers();
|
|
1226
|
-
appendOAuthStateCookieClear(headers);
|
|
1277
|
+
appendOAuthStateCookieClear(headers, canClearStateCookie ? stateCookie.name : null);
|
|
1227
1278
|
appendHeadersPreservingSetCookie(headers, recoveryHeaders);
|
|
1228
1279
|
const redirectUrl = appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus);
|
|
1229
1280
|
logger.info("Application initiated action returned without authorization code", {
|
|
@@ -1251,11 +1302,11 @@ async function handleCallback(request) {
|
|
|
1251
1302
|
callbackStage = "session_creation";
|
|
1252
1303
|
const sessionHeaders = await createAuthSession(request, newSessionData);
|
|
1253
1304
|
callbackStage = "storage";
|
|
1254
|
-
await deleteOAuthState(stateId);
|
|
1305
|
+
await deleteOAuthState(stateId, state);
|
|
1255
1306
|
const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
|
|
1256
1307
|
const headers = new Headers();
|
|
1257
1308
|
appendHeadersPreservingSetCookie(headers, sessionHeaders);
|
|
1258
|
-
appendOAuthStateCookieClear(headers);
|
|
1309
|
+
appendOAuthStateCookieClear(headers, canClearStateCookie ? stateCookie.name : null);
|
|
1259
1310
|
appendHeadersPreservingSetCookie(headers, recoveryHeaders);
|
|
1260
1311
|
logger.info("OAuth callback processed successfully");
|
|
1261
1312
|
return createRedirectResponse(appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus), { headers });
|
|
@@ -1265,9 +1316,15 @@ async function handleCallback(request) {
|
|
|
1265
1316
|
failureCode,
|
|
1266
1317
|
errorType: error instanceof Error ? error.name : "UnknownError"
|
|
1267
1318
|
});
|
|
1268
|
-
throw await createOAuthCallbackFailure(request, stateId, failureCode);
|
|
1319
|
+
throw await createOAuthCallbackFailure(request, stateId, failureCode, callbackState, stateCookie, canClearStateCookie);
|
|
1269
1320
|
}
|
|
1270
1321
|
}
|
|
1322
|
+
function createLocalLogoutResponse(request, headers) {
|
|
1323
|
+
const returnUrl = sanitizeOAuthReturnUrl(new URL(request.url).searchParams.get("returnUrl"), request);
|
|
1324
|
+
headers.append("Set-Cookie", serializeTemporaryAuthCookie(LOGOUT_RETURN_URL_COOKIE, "", { maxAge: 0 }));
|
|
1325
|
+
logger.info("Local application logout completed", { hasReturnUrl: Boolean(returnUrl) });
|
|
1326
|
+
return createRedirectResponse(getAbsoluteRedirectUrl(request, returnUrl || "/"), { headers });
|
|
1327
|
+
}
|
|
1271
1328
|
/**
|
|
1272
1329
|
* Logout user.
|
|
1273
1330
|
*
|
|
@@ -1276,7 +1333,8 @@ async function handleCallback(request) {
|
|
|
1276
1333
|
* `/auth/logout?logout=local` to clear only this app's Redis session without
|
|
1277
1334
|
* bouncing the browser through the identity provider or revoking SSO tokens.
|
|
1278
1335
|
*/
|
|
1279
|
-
async function logout(request) {
|
|
1336
|
+
async function logout(request, options = {}) {
|
|
1337
|
+
if (options.sessionInvalidated) return createLocalLogoutResponse(request, clearAuthSessionCookie());
|
|
1280
1338
|
try {
|
|
1281
1339
|
const config = getAuthConfig();
|
|
1282
1340
|
const url = new URL(request.url);
|
|
@@ -1286,15 +1344,9 @@ async function logout(request) {
|
|
|
1286
1344
|
clientId: config.clientId,
|
|
1287
1345
|
logoutScope
|
|
1288
1346
|
});
|
|
1347
|
+
if (logoutScope === "local") return createLocalLogoutResponse(request, await destroyAuthSession(request));
|
|
1289
1348
|
const sessionData = await getAuthSession(request);
|
|
1290
1349
|
const idToken = sessionData.idToken;
|
|
1291
|
-
if (logoutScope === "local") {
|
|
1292
|
-
const headers = await destroyAuthSession(request);
|
|
1293
|
-
const absoluteRedirectUrl = getAbsoluteRedirectUrl(request, returnUrl || "/");
|
|
1294
|
-
headers.append("Set-Cookie", serializeTemporaryAuthCookie(LOGOUT_RETURN_URL_COOKIE, "", { maxAge: 0 }));
|
|
1295
|
-
logger.info("Local application logout completed", { hasReturnUrl: Boolean(returnUrl) });
|
|
1296
|
-
return createRedirectResponse(absoluteRedirectUrl, { headers });
|
|
1297
|
-
}
|
|
1298
1350
|
const sessionHeaders = new Headers();
|
|
1299
1351
|
if (logoutScope === "all" && sessionData.userId) {
|
|
1300
1352
|
await revokeAndDestroyAllUserSessionsBestEffort(sessionData.userId);
|
|
@@ -1414,21 +1466,10 @@ async function handleFrontChannelLogout(request) {
|
|
|
1414
1466
|
return new Response(null, { status: 204 });
|
|
1415
1467
|
}
|
|
1416
1468
|
}
|
|
1417
|
-
function getAccessTokenExpiry(accessToken) {
|
|
1418
|
-
if (!accessToken) return null;
|
|
1419
|
-
try {
|
|
1420
|
-
const [, encodedPayload] = accessToken.split(".");
|
|
1421
|
-
if (!encodedPayload) return null;
|
|
1422
|
-
const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
|
|
1423
|
-
return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1e3 : null;
|
|
1424
|
-
} catch {
|
|
1425
|
-
return null;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
1469
|
function isSessionExpired(sessionData) {
|
|
1429
1470
|
const now = Date.now();
|
|
1430
1471
|
if (typeof sessionData.expiresAt === "number" && (!Number.isFinite(sessionData.expiresAt) || now >= sessionData.expiresAt)) return true;
|
|
1431
|
-
const accessTokenExpiry =
|
|
1472
|
+
const accessTokenExpiry = getTokenExpiry(sessionData);
|
|
1432
1473
|
return accessTokenExpiry !== null && now >= accessTokenExpiry;
|
|
1433
1474
|
}
|
|
1434
1475
|
/**
|
|
@@ -1468,75 +1509,171 @@ async function getAccessToken(request) {
|
|
|
1468
1509
|
return null;
|
|
1469
1510
|
}
|
|
1470
1511
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1512
|
+
const REFRESH_REQUEST_TIMEOUT_MS = 15e3;
|
|
1513
|
+
const REFRESH_LOCK_TTL_MS = 2e4;
|
|
1514
|
+
function refreshFailure(error, shouldLogout = false) {
|
|
1515
|
+
return {
|
|
1516
|
+
success: false,
|
|
1517
|
+
error,
|
|
1518
|
+
shouldLogout
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
function reusedRefreshResult(session) {
|
|
1522
|
+
const expiry = getTokenExpiry(session);
|
|
1523
|
+
return {
|
|
1524
|
+
success: true,
|
|
1525
|
+
tokens: {
|
|
1526
|
+
access_token: session.accessToken,
|
|
1527
|
+
refresh_token: session.refreshToken,
|
|
1528
|
+
id_token: session.idToken,
|
|
1529
|
+
expires_in: expiry === null ? void 0 : Math.max(0, Math.floor((expiry - Date.now()) / 1e3))
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
function hasNewUsableTokens(current, previous) {
|
|
1534
|
+
return Boolean(current.accessToken) && !isSessionExpired(current) && !hasSameAuthTokenGeneration(current, previous);
|
|
1535
|
+
}
|
|
1536
|
+
async function invalidateFailedRefresh(request, expected, owner, error) {
|
|
1537
|
+
const invalidated = await invalidateAuthSessionRefreshGeneration(expected, owner);
|
|
1538
|
+
if (invalidated === "invalidated" || invalidated === "missing") return {
|
|
1539
|
+
...refreshFailure(error, true),
|
|
1540
|
+
sessionInvalidated: true
|
|
1541
|
+
};
|
|
1542
|
+
const latest = await getAuthSession(request, { throwOnError: true });
|
|
1543
|
+
if (!latest.sessionId) return {
|
|
1544
|
+
...refreshFailure("No active session", true),
|
|
1545
|
+
sessionInvalidated: true
|
|
1546
|
+
};
|
|
1547
|
+
return hasNewUsableTokens(latest, expected) ? reusedRefreshResult(latest) : refreshFailure("Session changed while refreshing tokens");
|
|
1548
|
+
}
|
|
1549
|
+
/** Refresh only a signed, existing session, serialized across server processes. */
|
|
1474
1550
|
async function refreshTokens(request, refreshToken) {
|
|
1551
|
+
let lock;
|
|
1552
|
+
let attemptedSession;
|
|
1475
1553
|
try {
|
|
1476
|
-
const
|
|
1477
|
-
if (!
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
return {
|
|
1489
|
-
success: false,
|
|
1490
|
-
error: "No refresh token available",
|
|
1491
|
-
shouldLogout: true
|
|
1554
|
+
const initial = await getAuthSession(request, { throwOnError: true });
|
|
1555
|
+
if (!initial.sessionId) return {
|
|
1556
|
+
...refreshFailure("No active session", true),
|
|
1557
|
+
sessionInvalidated: true
|
|
1558
|
+
};
|
|
1559
|
+
if (refreshToken && refreshToken !== initial.refreshToken) return refreshFailure("Refresh token does not match active session", true);
|
|
1560
|
+
if (!initial.refreshToken) {
|
|
1561
|
+
const owner = crypto.randomUUID();
|
|
1562
|
+
if (!await acquireAuthSessionRefreshLock(initial.sessionId, owner, REFRESH_LOCK_TTL_MS)) return refreshFailure("Token refresh is still in progress");
|
|
1563
|
+
lock = {
|
|
1564
|
+
sessionId: initial.sessionId,
|
|
1565
|
+
owner
|
|
1492
1566
|
};
|
|
1567
|
+
return await invalidateFailedRefresh(request, {
|
|
1568
|
+
...initial,
|
|
1569
|
+
sessionId: initial.sessionId
|
|
1570
|
+
}, owner, "No refresh token available");
|
|
1493
1571
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1572
|
+
const discovered = await getOAuthConfig();
|
|
1573
|
+
const config = getAuthConfig();
|
|
1574
|
+
const refreshConfig = new oidc.Configuration(discovered.serverMetadata(), config.clientId, getClientMetadata(config), getClientAuthentication(config));
|
|
1575
|
+
refreshConfig.timeout = REFRESH_REQUEST_TIMEOUT_MS / 1e3;
|
|
1576
|
+
if (shouldAllowInsecureOidcRequests(config.authority)) oidc.allowInsecureRequests(refreshConfig);
|
|
1577
|
+
const owner = crypto.randomUUID();
|
|
1578
|
+
const deadline = Date.now() + REFRESH_LOCK_TTL_MS;
|
|
1579
|
+
let session;
|
|
1580
|
+
while (true) {
|
|
1581
|
+
if (await acquireAuthSessionRefreshLock(initial.sessionId, owner, REFRESH_LOCK_TTL_MS)) {
|
|
1582
|
+
lock = {
|
|
1583
|
+
sessionId: initial.sessionId,
|
|
1584
|
+
owner
|
|
1585
|
+
};
|
|
1586
|
+
session = await getAuthSession(request, { throwOnError: true });
|
|
1587
|
+
break;
|
|
1588
|
+
}
|
|
1589
|
+
const latest = await getAuthSession(request, { throwOnError: true });
|
|
1590
|
+
if (!latest.sessionId) return {
|
|
1591
|
+
...refreshFailure("No active session", true),
|
|
1592
|
+
sessionInvalidated: true
|
|
1500
1593
|
};
|
|
1594
|
+
if (!latest.refreshToken) return refreshFailure("Session changed while refreshing tokens");
|
|
1595
|
+
if (hasNewUsableTokens(latest, initial)) return reusedRefreshResult(latest);
|
|
1596
|
+
if (Date.now() >= deadline) return refreshFailure("Token refresh is still in progress");
|
|
1597
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1501
1598
|
}
|
|
1502
|
-
|
|
1599
|
+
if (!session.sessionId) return {
|
|
1600
|
+
...refreshFailure("No active session", true),
|
|
1601
|
+
sessionInvalidated: true
|
|
1602
|
+
};
|
|
1603
|
+
if (!session.refreshToken) return refreshFailure("Session changed while refreshing tokens");
|
|
1604
|
+
if (hasNewUsableTokens(session, initial)) return reusedRefreshResult(session);
|
|
1503
1605
|
logger.info("Initiating token refresh");
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
};
|
|
1606
|
+
attemptedSession = session;
|
|
1607
|
+
let timer;
|
|
1608
|
+
let result;
|
|
1609
|
+
try {
|
|
1610
|
+
result = await Promise.race([oidc.refreshTokenGrant(refreshConfig, session.refreshToken), new Promise((_, reject) => {
|
|
1611
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error("Token refresh timed out")), REFRESH_REQUEST_TIMEOUT_MS);
|
|
1612
|
+
})]);
|
|
1613
|
+
} finally {
|
|
1614
|
+
clearTimeout(timer);
|
|
1514
1615
|
}
|
|
1515
|
-
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
accessToken:
|
|
1616
|
+
if (!result.access_token) return refreshFailure("No access token in refresh response");
|
|
1617
|
+
const newRefreshToken = result.refresh_token || session.refreshToken;
|
|
1618
|
+
const expiresAt = getTokenExpiry({
|
|
1619
|
+
accessToken: result.access_token,
|
|
1620
|
+
expiresAt: typeof result.expires_in === "number" && Number.isFinite(result.expires_in) ? Date.now() + result.expires_in * 1e3 : void 0
|
|
1621
|
+
}) ?? void 0;
|
|
1622
|
+
if (isSessionExpired({
|
|
1623
|
+
accessToken: result.access_token,
|
|
1624
|
+
expiresAt
|
|
1625
|
+
})) return refreshFailure("Refresh response contains an expired access token");
|
|
1626
|
+
const saved = await saveRefreshedAuthSession({
|
|
1627
|
+
...session,
|
|
1628
|
+
sessionId: session.sessionId
|
|
1629
|
+
}, owner, {
|
|
1630
|
+
accessToken: result.access_token,
|
|
1519
1631
|
refreshToken: newRefreshToken,
|
|
1520
|
-
idToken:
|
|
1632
|
+
idToken: result.id_token ?? session.idToken,
|
|
1521
1633
|
expiresAt,
|
|
1522
1634
|
lastActivity: Date.now()
|
|
1523
1635
|
});
|
|
1636
|
+
if (saved === "missing") return {
|
|
1637
|
+
...refreshFailure("No active session", true),
|
|
1638
|
+
sessionInvalidated: true
|
|
1639
|
+
};
|
|
1640
|
+
if (saved !== "updated") {
|
|
1641
|
+
const latest = await getAuthSession(request, { throwOnError: true });
|
|
1642
|
+
if (!latest.sessionId) return {
|
|
1643
|
+
...refreshFailure("No active session", true),
|
|
1644
|
+
sessionInvalidated: true
|
|
1645
|
+
};
|
|
1646
|
+
if (!latest.refreshToken) return refreshFailure("Session changed while refreshing tokens");
|
|
1647
|
+
return hasNewUsableTokens(latest, session) ? reusedRefreshResult(latest) : refreshFailure("Session changed while refreshing tokens");
|
|
1648
|
+
}
|
|
1649
|
+
logger.info("Token refresh successful");
|
|
1524
1650
|
return {
|
|
1525
1651
|
success: true,
|
|
1526
1652
|
tokens: {
|
|
1527
|
-
access_token:
|
|
1653
|
+
access_token: result.access_token,
|
|
1528
1654
|
refresh_token: newRefreshToken,
|
|
1529
|
-
id_token:
|
|
1530
|
-
expires_in:
|
|
1655
|
+
id_token: result.id_token ?? session.idToken,
|
|
1656
|
+
expires_in: result.expires_in
|
|
1531
1657
|
}
|
|
1532
1658
|
};
|
|
1533
1659
|
} catch (error) {
|
|
1534
|
-
logger.error("Token refresh failed
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1660
|
+
logger.error("Token refresh failed", error instanceof Error ? error : void 0);
|
|
1661
|
+
const terminal = error instanceof oidc.ResponseBodyError && error.error === "invalid_grant" && error.status < 500;
|
|
1662
|
+
if (terminal && lock && attemptedSession) try {
|
|
1663
|
+
return await invalidateFailedRefresh(request, {
|
|
1664
|
+
...attemptedSession,
|
|
1665
|
+
sessionId: lock.sessionId
|
|
1666
|
+
}, lock.owner, getOAuthErrorMessage(error));
|
|
1667
|
+
} catch {
|
|
1668
|
+
return refreshFailure("Could not verify session after token refresh failure");
|
|
1669
|
+
}
|
|
1670
|
+
return refreshFailure(getOAuthErrorMessage(error), terminal);
|
|
1671
|
+
} finally {
|
|
1672
|
+
if (lock) try {
|
|
1673
|
+
await releaseAuthSessionRefreshLock(lock.sessionId, lock.owner);
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
logger.warn("Could not release token refresh lease", { error: error instanceof Error ? error.message : "Unknown error" });
|
|
1676
|
+
}
|
|
1540
1677
|
}
|
|
1541
1678
|
}
|
|
1542
1679
|
/**
|
|
@@ -1563,32 +1700,26 @@ function resolveRefreshThreshold() {
|
|
|
1563
1700
|
*/
|
|
1564
1701
|
async function shouldRefreshToken(request) {
|
|
1565
1702
|
try {
|
|
1566
|
-
const sessionData = await getAuthSession(request);
|
|
1567
|
-
const
|
|
1568
|
-
const sessionExpired =
|
|
1569
|
-
if (!sessionData.refreshToken
|
|
1570
|
-
|
|
1571
|
-
logger.info("Token expired and no refresh token, forcing local application logout");
|
|
1572
|
-
throw await logout(await createAutomaticLogoutRequest(request, "expired-no-refresh-token"));
|
|
1573
|
-
}
|
|
1574
|
-
return false;
|
|
1575
|
-
}
|
|
1576
|
-
if (!sessionData.expiresAt || !sessionData.accessToken) return false;
|
|
1577
|
-
let jwtExpiry = null;
|
|
1578
|
-
try {
|
|
1579
|
-
const tokenParts = sessionData.accessToken.split(".");
|
|
1580
|
-
if (tokenParts.length === 3) {
|
|
1581
|
-
const payload = JSON.parse(Buffer.from(tokenParts[1], "base64").toString());
|
|
1582
|
-
if (payload.exp) jwtExpiry = payload.exp * 1e3;
|
|
1583
|
-
}
|
|
1584
|
-
} catch {}
|
|
1585
|
-
return (jwtExpiry && jwtExpiry < sessionData.expiresAt ? jwtExpiry : sessionData.expiresAt) - now <= REFRESH_THRESHOLD;
|
|
1703
|
+
const sessionData = await getAuthSession(request, { throwOnError: true });
|
|
1704
|
+
const expiry = getTokenExpiry(sessionData);
|
|
1705
|
+
const sessionExpired = expiry !== null && Date.now() >= expiry;
|
|
1706
|
+
if (!sessionData.refreshToken) return sessionExpired;
|
|
1707
|
+
return Boolean(sessionData.accessToken) && expiry !== null && expiry - Date.now() <= REFRESH_THRESHOLD;
|
|
1586
1708
|
} catch (error) {
|
|
1587
1709
|
if (error instanceof Response) throw error;
|
|
1588
1710
|
logger.error("Error checking token refresh", error instanceof Error ? error : void 0);
|
|
1589
|
-
throw
|
|
1711
|
+
throw authUnavailableResponse();
|
|
1590
1712
|
}
|
|
1591
1713
|
}
|
|
1714
|
+
function authUnavailableResponse() {
|
|
1715
|
+
return new Response("Authentication temporarily unavailable", {
|
|
1716
|
+
status: 503,
|
|
1717
|
+
headers: {
|
|
1718
|
+
"Cache-Control": "no-store",
|
|
1719
|
+
"Retry-After": "5"
|
|
1720
|
+
}
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1592
1723
|
/**
|
|
1593
1724
|
* Auto-refresh tokens if needed
|
|
1594
1725
|
*/
|
|
@@ -1600,12 +1731,12 @@ async function autoRefreshTokens(request) {
|
|
|
1600
1731
|
if (result.success && result.tokens?.access_token) logger.info("Auto-refresh completed successfully");
|
|
1601
1732
|
else {
|
|
1602
1733
|
logger.error("Auto-refresh failed", void 0, { error: result.error ?? "Refresh response did not include an access token" });
|
|
1603
|
-
|
|
1604
|
-
throw await logout(await createAutomaticLogoutRequest(request, "refresh-failed"));
|
|
1734
|
+
if (result.shouldLogout !== true) throw authUnavailableResponse();
|
|
1735
|
+
throw await logout(await createAutomaticLogoutRequest(request, result.error === "No refresh token available" ? "expired-no-refresh-token" : "refresh-failed"), { sessionInvalidated: result.sessionInvalidated });
|
|
1605
1736
|
}
|
|
1606
1737
|
}
|
|
1607
1738
|
} catch (error) {
|
|
1608
|
-
if (error instanceof Response && error.status >= 300 && error.status < 400) throw error;
|
|
1739
|
+
if (error instanceof Response && (error.status >= 300 && error.status < 400 || error.status === 503)) throw error;
|
|
1609
1740
|
throw error;
|
|
1610
1741
|
}
|
|
1611
1742
|
}
|
|
@@ -1686,7 +1817,7 @@ async function applyConfiguredRouteResolution(request, user, currentPath, protec
|
|
|
1686
1817
|
if (result) throw createRedirectFromConfig(result);
|
|
1687
1818
|
return true;
|
|
1688
1819
|
} catch (error) {
|
|
1689
|
-
if (error instanceof Response && error.status >= 300 && error.status < 400) throw error;
|
|
1820
|
+
if (error instanceof Response && (error.status >= 300 && error.status < 400 || error.status === 503)) throw error;
|
|
1690
1821
|
if (!protectionConfig.handleRouteError) throw error;
|
|
1691
1822
|
const recovery = await protectionConfig.handleRouteError({
|
|
1692
1823
|
...context,
|
|
@@ -1706,7 +1837,7 @@ async function protectRoute(request, protection, loaderFn) {
|
|
|
1706
1837
|
await autoRefreshTokens(request);
|
|
1707
1838
|
return loaderFn(await getUser(request) ?? void 0);
|
|
1708
1839
|
} catch (error) {
|
|
1709
|
-
if (error instanceof Response && error.status >= 300 && error.status < 400) throw error;
|
|
1840
|
+
if (error instanceof Response && (error.status >= 300 && error.status < 400 || error.status === 503)) throw error;
|
|
1710
1841
|
return loaderFn(void 0);
|
|
1711
1842
|
}
|
|
1712
1843
|
case "auth":
|
|
@@ -1716,7 +1847,7 @@ async function protectRoute(request, protection, loaderFn) {
|
|
|
1716
1847
|
await applyConfiguredRouteResolution(request, user, url.pathname, protection);
|
|
1717
1848
|
return loaderFn(user);
|
|
1718
1849
|
} catch (error) {
|
|
1719
|
-
if (error instanceof Response && error.status >= 300 && error.status < 400) throw error;
|
|
1850
|
+
if (error instanceof Response && (error.status >= 300 && error.status < 400 || error.status === 503)) throw error;
|
|
1720
1851
|
throw await login(request, getLoginReturnUrl(request, url.pathname, protection));
|
|
1721
1852
|
}
|
|
1722
1853
|
case "onboarding-required": try {
|
|
@@ -1725,7 +1856,7 @@ async function protectRoute(request, protection, loaderFn) {
|
|
|
1725
1856
|
await applyConfiguredRouteResolution(request, user, url.pathname, "onboarding-required");
|
|
1726
1857
|
return loaderFn(user);
|
|
1727
1858
|
} catch (error) {
|
|
1728
|
-
if (error instanceof Response && error.status >= 300 && error.status < 400) throw error;
|
|
1859
|
+
if (error instanceof Response && (error.status >= 300 && error.status < 400 || error.status === 503)) throw error;
|
|
1729
1860
|
throw await login(request, getLoginReturnUrl(request, url.pathname, "onboarding-required"));
|
|
1730
1861
|
}
|
|
1731
1862
|
case "subscription-required": try {
|
|
@@ -1737,7 +1868,7 @@ async function protectRoute(request, protection, loaderFn) {
|
|
|
1737
1868
|
}
|
|
1738
1869
|
return loaderFn(user);
|
|
1739
1870
|
} catch (error) {
|
|
1740
|
-
if (error instanceof Response && error.status >= 300 && error.status < 400) throw error;
|
|
1871
|
+
if (error instanceof Response && (error.status >= 300 && error.status < 400 || error.status === 503)) throw error;
|
|
1741
1872
|
throw await login(request, getLoginReturnUrl(request, url.pathname, "subscription-required"));
|
|
1742
1873
|
}
|
|
1743
1874
|
default: throw new Error(`Unknown protection level: ${protection}`);
|
|
@@ -1782,16 +1913,7 @@ const subscriptionRoute = (request, loaderFn) => protectRoute(request, "subscrip
|
|
|
1782
1913
|
async function attemptTokenRefresh(request) {
|
|
1783
1914
|
try {
|
|
1784
1915
|
logger.info("Attempting token refresh due to 401 response");
|
|
1785
|
-
const
|
|
1786
|
-
if (!sessionData.refreshToken) {
|
|
1787
|
-
logger.warn("No refresh token available in session");
|
|
1788
|
-
return {
|
|
1789
|
-
success: false,
|
|
1790
|
-
error: "No refresh token available",
|
|
1791
|
-
shouldLogout: true
|
|
1792
|
-
};
|
|
1793
|
-
}
|
|
1794
|
-
const refreshResult = await refreshTokens(request, sessionData.refreshToken);
|
|
1916
|
+
const refreshResult = await refreshTokens(request);
|
|
1795
1917
|
if (!refreshResult.success) {
|
|
1796
1918
|
logger.error("Token refresh failed", void 0, {
|
|
1797
1919
|
error: refreshResult.error,
|
|
@@ -1800,7 +1922,8 @@ async function attemptTokenRefresh(request) {
|
|
|
1800
1922
|
return {
|
|
1801
1923
|
success: false,
|
|
1802
1924
|
error: refreshResult.error,
|
|
1803
|
-
shouldLogout: true
|
|
1925
|
+
shouldLogout: refreshResult.shouldLogout === true,
|
|
1926
|
+
...refreshResult.sessionInvalidated ? { sessionInvalidated: true } : {}
|
|
1804
1927
|
};
|
|
1805
1928
|
}
|
|
1806
1929
|
const tokens = refreshResult.tokens;
|
|
@@ -1809,7 +1932,7 @@ async function attemptTokenRefresh(request) {
|
|
|
1809
1932
|
return {
|
|
1810
1933
|
success: false,
|
|
1811
1934
|
error: "No access token in refresh response",
|
|
1812
|
-
shouldLogout:
|
|
1935
|
+
shouldLogout: false
|
|
1813
1936
|
};
|
|
1814
1937
|
}
|
|
1815
1938
|
logger.info("Token refresh successful");
|
|
@@ -1822,7 +1945,7 @@ async function attemptTokenRefresh(request) {
|
|
|
1822
1945
|
return {
|
|
1823
1946
|
success: false,
|
|
1824
1947
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
1825
|
-
shouldLogout:
|
|
1948
|
+
shouldLogout: false
|
|
1826
1949
|
};
|
|
1827
1950
|
}
|
|
1828
1951
|
}
|
|
@@ -1839,6 +1962,6 @@ function shouldAttemptTokenRefresh(response, hasAuth) {
|
|
|
1839
1962
|
return hasAuth && isTokenExpiredResponse(response);
|
|
1840
1963
|
}
|
|
1841
1964
|
//#endregion
|
|
1842
|
-
export { resetAuthClaimMapping as A, handleCallback as C, logout as D, login as E,
|
|
1965
|
+
export { resetAuthClaimMapping as A, handleCallback as C, logout as D, login as E, OAUTH_RECOVERY_COOKIE_NAME as F, OAuthCallbackError as I, OAuthCallbackFailureCodes as L, consumeOAuthRecoveryIntent as M, getOAuthRecoveryCookiePath as N, refreshTokens as O, isOAuthRecoveryEligibleCallback as P, isOAuthCallbackError as R, handleBackChannelLogout as S, hasAuthError as T, createAuthorizationResponseUrl as _, configureRouteProtection as a, getAuthError as b, protectRoute as c, resetRouteProtectionConfig as d, shouldRefreshToken as f, configureAuthClaimMapping as g, clearAuthServerCache as h, authRoute as i, clearOAuthRecoveryIntentHeaders as j, requireAuth as k, protectedRoute as l, clearAuthErrorHeaders as m, isTokenExpiredResponse as n, onboardingRoute as o, subscriptionRoute as p, shouldAttemptTokenRefresh as r, policyRoute as s, attemptTokenRefresh as t, publicRoute as u, extractUserInfo as v, handleFrontChannelLogout as w, getUser as x, getAccessToken as y, isRecoverableOAuthCallbackError as z };
|
|
1843
1966
|
|
|
1844
|
-
//# sourceMappingURL=server-
|
|
1967
|
+
//# sourceMappingURL=server-W3MUOcah.mjs.map
|