@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +10 -6
  3. package/dist/api-client/fetch-client.server.d.ts.map +1 -1
  4. package/dist/api-client/server.mjs +1 -1
  5. package/dist/auth/auth.server.d.ts +4 -14
  6. package/dist/auth/auth.server.d.ts.map +1 -1
  7. package/dist/auth/oauth-recovery.server.d.ts +5 -11
  8. package/dist/auth/oauth-recovery.server.d.ts.map +1 -1
  9. package/dist/auth/oauth-security.d.ts.map +1 -1
  10. package/dist/auth/oauth-transaction-cookies.server.d.ts +18 -0
  11. package/dist/auth/oauth-transaction-cookies.server.d.ts.map +1 -0
  12. package/dist/auth/redis-session-storage.server.d.ts +24 -9
  13. package/dist/auth/redis-session-storage.server.d.ts.map +1 -1
  14. package/dist/auth/route-protection.server.d.ts.map +1 -1
  15. package/dist/auth/server.d.ts +1 -1
  16. package/dist/auth/server.d.ts.map +1 -1
  17. package/dist/auth/server.mjs +3 -3
  18. package/dist/auth/token-expiry.d.ts +6 -0
  19. package/dist/auth/token-expiry.d.ts.map +1 -0
  20. package/dist/auth/token-refresh.server.d.ts.map +1 -1
  21. package/dist/auth/types.d.ts +2 -0
  22. package/dist/auth/types.d.ts.map +1 -1
  23. package/dist/react-router/server.mjs +4 -4
  24. package/dist/{redis-session-storage.server-DLJ0B7-C.mjs → redis-session-storage.server--i4vhYxs.mjs} +119 -25
  25. package/dist/redis-session-storage.server--i4vhYxs.mjs.map +1 -0
  26. package/dist/{server-BJTz5-BE.mjs → server-W3MUOcah.mjs} +361 -238
  27. package/dist/server-W3MUOcah.mjs.map +1 -0
  28. package/dist/{server-pnsLybIH.mjs → server-iCmy3RW8.mjs} +11 -4
  29. package/dist/{server-pnsLybIH.mjs.map → server-iCmy3RW8.mjs.map} +1 -1
  30. package/dist/server.mjs +4 -4
  31. package/dist/tanstack-start/server.mjs +4 -4
  32. package/dist/tenant/server.mjs +3 -2
  33. package/dist/tenant/server.mjs.map +1 -1
  34. package/dist/tenant/tenant-cookie.server.d.ts.map +1 -1
  35. package/docs/adapters.md +9 -6
  36. package/docs/module-reference.md +1 -0
  37. package/package.json +1 -1
  38. package/dist/redis-session-storage.server-DLJ0B7-C.mjs.map +0 -1
  39. package/dist/server-BJTz5-BE.mjs.map +0 -1
@@ -1,7 +1,9 @@
1
1
  import { logger } from "./logging/index.mjs";
2
- import { D as OAUTH_RECOVERY_TTL_SECONDS, E as OAUTH_RECOVERY_TTL_MS, O as OAUTH_STATE_TTL_MS, T as updateAuthSession, a as createAuthSession, c as deleteOAuthRecoveryRecord, f as destroyAuthSessionsByIdentitySession, g as getAuthSession, h as destroyAuthSessionsForUser, i as consumeOAuthRecoveryRecord, k as OAUTH_STATE_TTL_SECONDS, l as deleteOAuthState, m as destroyAuthSessionsBySid, o as createOAuthRecoveryRecord, s as createOAuthState, t as AUTH_ERROR_COOKIE_PREFIX, u as destroyAuthSession, x as listAuthSessionDataForUser, y as getOAuthState } from "./redis-session-storage.server-DLJ0B7-C.mjs";
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 (process.env.NODE_ENV === "production") segments.push("Secure");
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
- const OAUTH_RECOVERY_COOKIE_NAME = `${AUTH_ERROR_COOKIE_PREFIX}_oauth_recovery`;
213
- function escapeRegExp$1(value) {
214
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
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
- `${OAUTH_RECOVERY_COOKIE_NAME}=${encodeURIComponent(ticket)}`,
320
+ `${cookieName}=${encodeURIComponent(ticket)}`,
262
321
  `Path=${callbackPath}`,
263
322
  "HttpOnly",
264
323
  "SameSite=Lax",
265
324
  `Max-Age=${maxAge}`
266
325
  ];
267
- if (process.env.NODE_ENV === "production") segments.push("Secure");
326
+ if (env.NODE_ENV === "production") segments.push("Secure");
268
327
  return segments.join("; ");
269
328
  }
270
- function appendRecoveryCookieClear(headers, callbackPath) {
271
- headers.append("Set-Cookie", serializeRecoveryCookie("", 0, callbackPath));
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
- appendRecoveryCookieClear(headers, getOAuthRecoveryCookiePath(source));
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
- return hasNonEmptyParam(url, "code") && hasNonEmptyParam(url, "state") && !url.searchParams.has("error") && !url.searchParams.has("kc_action") && !url.searchParams.has("kc_action_status");
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 ticket = getCookieValue(request);
392
+ const state = getOAuthCallbackState(request);
393
+ const cookie = getRecoveryCookie(request, state);
333
394
  const headers = new Headers();
334
- if (!hasCookie(request)) return headers;
335
- if (ticket) await deleteOAuthRecoveryRecord(ticket);
336
- appendRecoveryCookieClear(headers, getOAuthRecoveryCookiePath(request));
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 cleanupHeaders = clearOAuthRecoveryIntentHeaders(request);
349
- const cleanupSetCookies = getSetCookieValues(cleanupHeaders);
350
- if (!isRecoverableOAuthCallbackError(callbackError) || !isOAuthRecoveryEligibleCallback(request)) return {
351
- intent: null,
352
- cleanupHeaders,
353
- cleanupSetCookies
354
- };
355
- const ticket = getCookieValue(request);
356
- const callbackState = getCallbackState(request);
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
- intent: null,
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 (process.env.NODE_ENV === "production") return false;
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
- headers.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, "", { maxAge: 0 }));
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(OAUTH_STATE_COOKIE, oauthStateId, { maxAge: OAUTH_STATE_TTL_SECONDS }));
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
- stateId = getOAuthStateIdFromRequest(request);
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 = url.searchParams.get("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 = getAccessTokenExpiry(sessionData.accessToken);
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
- * Refresh tokens
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 sessionData = await getAuthSession(request);
1477
- if (!sessionData.sessionId) {
1478
- logger.warn("Cannot refresh tokens without an active Redis session");
1479
- return {
1480
- success: false,
1481
- error: "No active session",
1482
- shouldLogout: true
1483
- };
1484
- }
1485
- const sessionRefreshToken = sessionData.refreshToken;
1486
- if (!sessionRefreshToken) {
1487
- logger.warn("No refresh token available");
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
- if (refreshToken && refreshToken !== sessionRefreshToken) {
1495
- logger.warn("Rejected refresh token that does not match the active Redis session");
1496
- return {
1497
- success: false,
1498
- error: "Refresh token does not match active session",
1499
- shouldLogout: true
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
- const tokenToRefresh = sessionRefreshToken;
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
- const oidcConfig = await getOAuthConfig();
1505
- const refreshResult = await oidc.refreshTokenGrant(oidcConfig, tokenToRefresh);
1506
- logger.info("Token refresh successful");
1507
- if (!refreshResult.access_token) {
1508
- logger.error("Token refresh returned no access token");
1509
- return {
1510
- success: false,
1511
- error: "No access token in refresh response",
1512
- shouldLogout: true
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
- const newRefreshToken = refreshResult.refresh_token || tokenToRefresh;
1516
- const expiresAt = refreshResult.expires_in ? Date.now() + refreshResult.expires_in * 1e3 : sessionData.expiresAt;
1517
- await updateAuthSession(request, {
1518
- accessToken: refreshResult.access_token,
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: refreshResult.id_token,
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: refreshResult.access_token,
1653
+ access_token: result.access_token,
1528
1654
  refresh_token: newRefreshToken,
1529
- id_token: refreshResult.id_token,
1530
- expires_in: refreshResult.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 with exception", error instanceof Error ? error : void 0);
1535
- return {
1536
- success: false,
1537
- error: getOAuthErrorMessage(error),
1538
- shouldLogout: true
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 now = Date.now();
1568
- const sessionExpired = typeof sessionData.expiresAt === "number" && now >= sessionData.expiresAt;
1569
- if (!sessionData.refreshToken || sessionData.refreshToken.length === 0) {
1570
- if (sessionExpired) {
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 error;
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
- logger.error("Refresh token unusable, forcing local application logout");
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 sessionData = await getAuthSession(request);
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: true
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: true
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, OAuthCallbackError as F, OAuthCallbackFailureCodes as I, isOAuthCallbackError as L, clearOAuthRecoveryIntentHeaders as M, consumeOAuthRecoveryIntent as N, refreshTokens as O, getOAuthRecoveryCookiePath as P, isRecoverableOAuthCallbackError 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, OAUTH_RECOVERY_COOKIE_NAME 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 };
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-BJTz5-BE.mjs.map
1967
+ //# sourceMappingURL=server-W3MUOcah.mjs.map