@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,4 +1,5 @@
1
1
  import { logger } from "./logging/index.mjs";
2
+ import { env } from "node:process";
2
3
  import Redis from "ioredis";
3
4
  import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4
5
  //#region src/auth/oauth-state-config.ts
@@ -25,6 +26,22 @@ const OAUTH_STATE_TTL_MS = OAUTH_STATE_TTL_SECONDS * 1e3;
25
26
  const OAUTH_RECOVERY_TTL_SECONDS = Math.max(DEFAULT_OAUTH_RECOVERY_TTL_SECONDS, OAUTH_STATE_TTL_SECONDS + 60);
26
27
  const OAUTH_RECOVERY_TTL_MS = OAUTH_RECOVERY_TTL_SECONDS * 1e3;
27
28
  //#endregion
29
+ //#region src/auth/token-expiry.ts
30
+ /** JWT expiry is a scheduling hint; session authority still comes from Redis. */
31
+ function getTokenExpiry(session) {
32
+ const expiries = [];
33
+ if (typeof session.expiresAt === "number" && Number.isFinite(session.expiresAt)) expiries.push(session.expiresAt);
34
+ try {
35
+ const parts = session.accessToken?.split(".");
36
+ const payload = parts?.length === 3 ? parts[1] : void 0;
37
+ if (payload) {
38
+ const { exp } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
39
+ if (typeof exp === "number" && Number.isFinite(exp) && Number.isFinite(exp * 1e3)) expiries.push(exp * 1e3);
40
+ }
41
+ } catch {}
42
+ return expiries.length > 0 ? Math.min(...expiries) : null;
43
+ }
44
+ //#endregion
28
45
  //#region src/auth/redis-session-storage.server.ts
29
46
  /**
30
47
  * Redis Session Storage Server Module
@@ -51,6 +68,7 @@ const REDIS_KEYS = {
51
68
  oauthState: (stateId) => `${KEY_PREFIX}oauth:state:${stateId}`,
52
69
  oauthRecovery: (ticket) => `${KEY_PREFIX}oauth:recovery:${ticket}`,
53
70
  session: (sessionId) => `${KEY_PREFIX}session:${sessionId}`,
71
+ sessionRefreshLock: (sessionId) => `${KEY_PREFIX}session:refresh-lock:${sessionId}`,
54
72
  sessionByUser: (userId) => `${KEY_PREFIX}session:index:user:${userId}`,
55
73
  sessionBySid: (sid) => `${KEY_PREFIX}session:index:sid:${sid}`,
56
74
  sessionBySessionState: (sessionState) => `${KEY_PREFIX}session:index:session_state:${sessionState}`
@@ -93,14 +111,14 @@ function resolvePositiveIntegerEnv(name, defaultValue) {
93
111
  function resolveSessionSecret() {
94
112
  const secret = process.env.SESSION_SECRET?.trim();
95
113
  if (!secret || secret === DEFAULT_SESSION_SECRET) {
96
- if (process.env.NODE_ENV === "production") throw new Error("SESSION_SECRET must be set to a unique random value in production.");
114
+ if (env.NODE_ENV === "production") throw new Error("SESSION_SECRET must be set to a unique random value in production.");
97
115
  return DEFAULT_SESSION_SECRET;
98
116
  }
99
117
  if (secret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters long.");
100
118
  return secret;
101
119
  }
102
120
  function resolveCookieSecure() {
103
- return resolveBooleanEnv("SESSION_COOKIE_SECURE", process.env.NODE_ENV === "production");
121
+ return resolveBooleanEnv("SESSION_COOKIE_SECURE", env.NODE_ENV === "production");
104
122
  }
105
123
  function resolveCookieSameSite() {
106
124
  const value = process.env.SESSION_COOKIE_SAME_SITE?.trim().toLowerCase();
@@ -109,8 +127,8 @@ function resolveCookieSameSite() {
109
127
  return sameSite;
110
128
  }
111
129
  function resolveSessionEncryptionEnabled() {
112
- const enabled = resolveBooleanEnv("SESSION_ENCRYPTION", process.env.NODE_ENV === "production");
113
- if (process.env.NODE_ENV === "production" && !enabled) throw new Error("SESSION_ENCRYPTION cannot be disabled in production.");
130
+ const enabled = resolveBooleanEnv("SESSION_ENCRYPTION", env.NODE_ENV === "production");
131
+ if (env.NODE_ENV === "production" && !enabled) throw new Error("SESSION_ENCRYPTION cannot be disabled in production.");
114
132
  return enabled;
115
133
  }
116
134
  function decodeBase64Url(value) {
@@ -250,7 +268,7 @@ function decodeSessionData(value) {
250
268
  decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
251
269
  return Buffer.concat([decipher.update(Buffer.from(ciphertextValue, "base64url")), decipher.final()]).toString("utf8");
252
270
  }
253
- async function readSessionData(sessionId) {
271
+ async function readSessionData(sessionId, throwOnError = false) {
254
272
  try {
255
273
  const sessionData = await getRedis().get(REDIS_KEYS.session(sessionId));
256
274
  if (!sessionData) {
@@ -258,9 +276,10 @@ async function readSessionData(sessionId) {
258
276
  return null;
259
277
  }
260
278
  await getRedis().expire(REDIS_KEYS.session(sessionId), SESSION_TTL);
261
- return parseSessionData(sessionData);
279
+ return throwOnError ? JSON.parse(decodeSessionData(sessionData)) : parseSessionData(sessionData);
262
280
  } catch (error) {
263
281
  logger.error("Failed to read session from Redis", error instanceof Error ? error : void 0);
282
+ if (throwOnError) throw error;
264
283
  return null;
265
284
  }
266
285
  }
@@ -352,10 +371,10 @@ async function createAuthSession(request, sessionData) {
352
371
  await writeSessionData(nextSessionId, nextSessionData);
353
372
  return new Headers({ "Set-Cookie": serializeSessionCookieValue(nextSessionId, { maxAge: SESSION_TTL }) });
354
373
  }
355
- async function getAuthSession(request) {
374
+ async function getAuthSession(request, options = {}) {
356
375
  const sessionId = await getSessionIdFromRequest(request);
357
376
  if (!sessionId) return {};
358
- const session = await readSessionData(sessionId);
377
+ const session = await readSessionData(sessionId, options.throwOnError);
359
378
  if (!session) return {};
360
379
  return {
361
380
  sessionId,
@@ -375,6 +394,77 @@ async function getAuthSession(request) {
375
394
  lastActivity: session.lastActivity
376
395
  };
377
396
  }
397
+ /** Internal refresh coordination. The owner is never derived from client input. */
398
+ async function acquireAuthSessionRefreshLock(sessionId, owner, ttlMs) {
399
+ return await getRedis().set(REDIS_KEYS.sessionRefreshLock(sessionId), owner, "PX", ttlMs, "NX") === "OK";
400
+ }
401
+ async function releaseAuthSessionRefreshLock(sessionId, owner) {
402
+ await getRedis().eval(`
403
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
404
+ return redis.call('DEL', KEYS[1])
405
+ end
406
+ return 0
407
+ `, 1, REDIS_KEYS.sessionRefreshLock(sessionId), owner);
408
+ }
409
+ function hasSameAuthTokenGeneration(current, expected) {
410
+ return current.accessToken === expected.accessToken && current.refreshToken === expected.refreshToken && current.idToken === expected.idToken && current.expiresAt === expected.expiresAt;
411
+ }
412
+ /** Delete only the rejected generation while its refresh lease is still owned. */
413
+ async function invalidateAuthSessionRefreshGeneration(expected, owner) {
414
+ const sessionKey = REDIS_KEYS.session(expected.sessionId);
415
+ for (let attempt = 0; attempt < 8; attempt += 1) {
416
+ const snapshot = await getRedis().get(sessionKey);
417
+ if (!snapshot) return "missing";
418
+ const current = JSON.parse(decodeSessionData(snapshot));
419
+ if (!hasSameAuthTokenGeneration(current, expected)) return "changed";
420
+ const indexes = getSessionIndexKeys(current);
421
+ const result = Number(await getRedis().eval(`
422
+ if redis.call('GET', KEYS[2]) ~= ARGV[1] then return -1 end
423
+ local current = redis.call('GET', KEYS[1])
424
+ if not current then return -2 end
425
+ if current ~= ARGV[2] then return 0 end
426
+ redis.call('DEL', KEYS[1])
427
+ for i = 3, #KEYS do redis.call('SREM', KEYS[i], ARGV[3]) end
428
+ return 1
429
+ `, 2 + indexes.length, sessionKey, REDIS_KEYS.sessionRefreshLock(expected.sessionId), ...indexes, owner, snapshot, expected.sessionId));
430
+ if (result === 1) return "invalidated";
431
+ if (result === -1) return "lock-lost";
432
+ if (result === -2) return "missing";
433
+ }
434
+ throw new Error("Session changed repeatedly during refresh invalidation");
435
+ }
436
+ /**
437
+ * Merge tokens with the latest session and atomically compare the encrypted
438
+ * snapshot and lease owner. Redis never needs the session encryption key.
439
+ * A concurrent logout or replacement token generation cannot be overwritten.
440
+ */
441
+ async function saveRefreshedAuthSession(expected, owner, updates) {
442
+ const sessionKey = REDIS_KEYS.session(expected.sessionId);
443
+ for (let attempt = 0; attempt < 8; attempt += 1) {
444
+ const snapshot = await getRedis().get(sessionKey);
445
+ if (!snapshot) return "missing";
446
+ const current = JSON.parse(decodeSessionData(snapshot));
447
+ if (!hasSameAuthTokenGeneration(current, expected)) return "changed";
448
+ const next = mergeSessionData(current, updates);
449
+ const indexes = getSessionIndexKeys(next);
450
+ const result = Number(await getRedis().eval(`
451
+ if redis.call('GET', KEYS[2]) ~= ARGV[1] then return -1 end
452
+ local current = redis.call('GET', KEYS[1])
453
+ if not current then return -2 end
454
+ if current ~= ARGV[2] then return 0 end
455
+ redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[4])
456
+ for i = 3, #KEYS do
457
+ redis.call('SADD', KEYS[i], ARGV[5])
458
+ redis.call('EXPIRE', KEYS[i], ARGV[4])
459
+ end
460
+ return 1
461
+ `, 2 + indexes.length, sessionKey, REDIS_KEYS.sessionRefreshLock(expected.sessionId), ...indexes, owner, snapshot, encodeSessionData(next), SESSION_TTL, expected.sessionId));
462
+ if (result === 1) return "updated";
463
+ if (result === -1) return "lock-lost";
464
+ if (result === -2) return "missing";
465
+ }
466
+ throw new Error("Session changed repeatedly during token refresh");
467
+ }
378
468
  async function updateAuthSession(request, updates) {
379
469
  const currentSessionId = await getSessionIdFromRequest(request);
380
470
  const currentSessionData = currentSessionId ? await readSessionData(currentSessionId) ?? {} : {};
@@ -388,6 +478,10 @@ async function updateAuthSession(request, updates) {
388
478
  async function destroyAuthSession(request) {
389
479
  const sessionId = await getSessionIdFromRequest(request);
390
480
  if (sessionId) await deleteSessionData(sessionId);
481
+ return clearAuthSessionCookie();
482
+ }
483
+ /** Cookie cleanup after a generation was already invalidated atomically. */
484
+ function clearAuthSessionCookie() {
391
485
  return new Headers({ "Set-Cookie": serializeExpiredSessionCookie() });
392
486
  }
393
487
  async function getCurrentAuthSessionId(request) {
@@ -445,21 +539,10 @@ async function requireAuthSession(request) {
445
539
  if (!sessionData.userId || !sessionData.user || !sessionData.accessToken || isSessionDataExpired(sessionData)) throw new Response("Unauthorized", { status: 401 });
446
540
  return sessionData;
447
541
  }
448
- function getJwtExpiry(accessToken) {
449
- if (!accessToken) return null;
450
- try {
451
- const [, encodedPayload] = accessToken.split(".");
452
- if (!encodedPayload) return null;
453
- const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
454
- return typeof payload.exp === "number" && Number.isFinite(payload.exp) ? payload.exp * 1e3 : null;
455
- } catch {
456
- return null;
457
- }
458
- }
459
542
  function isSessionDataExpired(sessionData) {
460
543
  const now = Date.now();
461
544
  if (typeof sessionData.expiresAt === "number" && (!Number.isFinite(sessionData.expiresAt) || now >= sessionData.expiresAt)) return true;
462
- const jwtExpiry = getJwtExpiry(sessionData.accessToken);
545
+ const jwtExpiry = getTokenExpiry(sessionData);
463
546
  return jwtExpiry !== null && now >= jwtExpiry;
464
547
  }
465
548
  async function isSessionValid(request) {
@@ -496,9 +579,21 @@ async function getOAuthState(stateId, options) {
496
579
  return null;
497
580
  }
498
581
  }
499
- async function deleteOAuthState(stateId) {
582
+ const DELETE_OAUTH_STATE_FOR_CALLBACK_SCRIPT = `
583
+ local value = redis.call('GET', KEYS[1])
584
+ if not value then
585
+ return 0
586
+ end
587
+ local ok, record = pcall(cjson.decode, value)
588
+ if not ok or type(record) ~= 'table' or record.state ~= ARGV[1] then
589
+ return 0
590
+ end
591
+ return redis.call('DEL', KEYS[1])
592
+ `;
593
+ async function deleteOAuthState(stateId, expectedState) {
500
594
  const key = REDIS_KEYS.oauthState(stateId);
501
- await getRedis().del(key);
595
+ if (expectedState === void 0) await getRedis().del(key);
596
+ else await getRedis().eval(DELETE_OAUTH_STATE_FOR_CALLBACK_SCRIPT, 1, key, expectedState);
502
597
  logger.debug("OAuth state deleted", { hasStateId: true });
503
598
  }
504
599
  const CONSUME_OAUTH_RECOVERY_RECORD_SCRIPT = `
@@ -508,7 +603,6 @@ if not value then
508
603
  end
509
604
  local ok, record = pcall(cjson.decode, value)
510
605
  if not ok or type(record) ~= 'table' or type(record.state) ~= 'string' then
511
- redis.call('DEL', KEYS[1])
512
606
  return '__invalid__'
513
607
  end
514
608
  if record.state ~= ARGV[1] then
@@ -579,6 +673,6 @@ async function closeRedisConnection() {
579
673
  }
580
674
  }
581
675
  //#endregion
582
- export { listCurrentUserAuthSessions as C, OAUTH_RECOVERY_TTL_SECONDS as D, OAUTH_RECOVERY_TTL_MS as E, OAUTH_STATE_TTL_MS as O, listAuthSessionsForUser as S, updateAuthSession as T, getAuthSessionById as _, createAuthSession as a, isSessionValid as b, deleteOAuthRecoveryRecord as c, destroyAuthSessionById as d, destroyAuthSessionsByIdentitySession as f, getAuthSession as g, destroyAuthSessionsForUser as h, consumeOAuthRecoveryRecord as i, OAUTH_STATE_TTL_SECONDS as k, deleteOAuthState as l, destroyAuthSessionsBySid as m, cleanupExpiredOAuthStates as n, createOAuthRecoveryRecord as o, destroyAuthSessionsBySessionState as p, closeRedisConnection as r, createOAuthState as s, AUTH_ERROR_COOKIE_PREFIX as t, destroyAuthSession as u, getCurrentAuthSessionId as v, requireAuthSession as w, listAuthSessionDataForUser as x, getOAuthState as y };
676
+ export { saveRefreshedAuthSession as A, invalidateAuthSessionRefreshGeneration as C, listCurrentUserAuthSessions as D, listAuthSessionsForUser as E, OAUTH_STATE_TTL_MS as F, OAUTH_STATE_TTL_SECONDS as I, getTokenExpiry as M, OAUTH_RECOVERY_TTL_MS as N, releaseAuthSessionRefreshLock as O, OAUTH_RECOVERY_TTL_SECONDS as P, hasSameAuthTokenGeneration as S, listAuthSessionDataForUser as T, destroyAuthSessionsForUser as _, closeRedisConnection as a, getCurrentAuthSessionId as b, createOAuthRecoveryRecord as c, deleteOAuthState as d, destroyAuthSession as f, destroyAuthSessionsBySid as g, destroyAuthSessionsBySessionState as h, clearAuthSessionCookie as i, updateAuthSession as j, requireAuthSession as k, createOAuthState as l, destroyAuthSessionsByIdentitySession as m, acquireAuthSessionRefreshLock as n, consumeOAuthRecoveryRecord as o, destroyAuthSessionById as p, cleanupExpiredOAuthStates as r, createAuthSession as s, AUTH_ERROR_COOKIE_PREFIX as t, deleteOAuthRecoveryRecord as u, getAuthSession as v, isSessionValid as w, getOAuthState as x, getAuthSessionById as y };
583
677
 
584
- //# sourceMappingURL=redis-session-storage.server-DLJ0B7-C.mjs.map
678
+ //# sourceMappingURL=redis-session-storage.server--i4vhYxs.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-session-storage.server--i4vhYxs.mjs","names":["resolvePositiveIntegerEnv","spineRuntimeEnv"],"sources":["../src/auth/oauth-state-config.ts","../src/auth/token-expiry.ts","../src/auth/redis-session-storage.server.ts"],"sourcesContent":["const DEFAULT_OAUTH_STATE_TTL_SECONDS = 10 * 60;\nconst DEFAULT_OAUTH_RECOVERY_TTL_SECONDS = 30 * 60;\n\nfunction resolvePositiveIntegerEnv(name: string, defaultValue: number): number {\n const value = process.env[name]?.trim();\n if (!value) {\n return defaultValue;\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed <= 0) {\n throw new Error(`${name} must be a positive integer.`);\n }\n\n return parsed;\n}\n\n/**\n * OAuth state lifetime shared by Redis expiry and callback age validation.\n * This module is internal and is not part of a package export entry point.\n */\nexport const OAUTH_STATE_TTL_SECONDS = resolvePositiveIntegerEnv(\n 'OAUTH_STATE_TTL',\n DEFAULT_OAUTH_STATE_TTL_SECONDS,\n);\n\nexport const OAUTH_STATE_TTL_MS = OAUTH_STATE_TTL_SECONDS * 1000;\n\n/**\n * Lifetime for the server-side recovery ticket. It deliberately outlives\n * the primary OAuth transaction so an IdP callback that arrives after state\n * expiry can still recover once, while remaining short-lived.\n */\nexport const OAUTH_RECOVERY_TTL_SECONDS = Math.max(\n DEFAULT_OAUTH_RECOVERY_TTL_SECONDS,\n OAUTH_STATE_TTL_SECONDS + 60,\n);\n\nexport const OAUTH_RECOVERY_TTL_MS = OAUTH_RECOVERY_TTL_SECONDS * 1000;\n","/** JWT expiry is a scheduling hint; session authority still comes from Redis. */\nexport function getTokenExpiry(session: { accessToken?: string; expiresAt?: number }): number | null {\n const expiries: number[] = [];\n if (typeof session.expiresAt === 'number' && Number.isFinite(session.expiresAt)) {\n expiries.push(session.expiresAt);\n }\n try {\n const parts = session.accessToken?.split('.');\n const payload = parts?.length === 3 ? parts[1] : undefined;\n if (payload) {\n const { exp } = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown };\n if (typeof exp === 'number' && Number.isFinite(exp) && Number.isFinite(exp * 1000)) {\n expiries.push(exp * 1000);\n }\n }\n } catch {\n // Providers may issue opaque access tokens.\n }\n return expiries.length > 0 ? Math.min(...expiries) : null;\n}\n","import { env as spineRuntimeEnv } from 'node:process';\n/**\n * Redis Session Storage Server Module\n *\n * Redis-backed session storage for authentication.\n * Provides secure session management with automatic expiration,\n * without depending on a framework-specific session implementation.\n */\n\nimport Redis from 'ioredis';\nimport { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';\nimport type { AuthSessionSummary, SessionData, OAuthState } from './types';\nimport {\n OAUTH_RECOVERY_TTL_SECONDS,\n OAUTH_STATE_TTL_SECONDS,\n} from './oauth-state-config';\nimport { logger } from '../logging';\nimport { getTokenExpiry } from './token-expiry';\n\n// ============================================================================\n// Redis Client Setup\n// ============================================================================\n\nlet redis: Redis | null = null;\n\nfunction getRedis(): Redis {\n if (!redis) {\n redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');\n\n redis.on('error', (err) => {\n logger.error('Redis Client Error', err);\n });\n\n redis.on('connect', () => {\n logger.info('Redis connected successfully');\n });\n }\n return redis;\n}\n\n// ============================================================================\n// Redis Key Patterns\n// ============================================================================\n\nconst KEY_PREFIX = process.env.REDIS_KEY_PREFIX || '';\n\nconst REDIS_KEYS = {\n oauthState: (stateId: string) => `${KEY_PREFIX}oauth:state:${stateId}`,\n oauthRecovery: (ticket: string) => `${KEY_PREFIX}oauth:recovery:${ticket}`,\n session: (sessionId: string) => `${KEY_PREFIX}session:${sessionId}`,\n sessionRefreshLock: (sessionId: string) => `${KEY_PREFIX}session:refresh-lock:${sessionId}`,\n sessionByUser: (userId: string) => `${KEY_PREFIX}session:index:user:${userId}`,\n sessionBySid: (sid: string) => `${KEY_PREFIX}session:index:sid:${sid}`,\n sessionBySessionState: (sessionState: string) => `${KEY_PREFIX}session:index:session_state:${sessionState}`,\n};\n\nconst DEFAULT_SESSION_SECRET = 'default-secret-change-in-production';\nconst DEFAULT_SESSION_TTL = 60 * 60 * 24 * 7;\n\nconst SESSION_TTL = resolvePositiveIntegerEnv('SESSION_DEFAULT_TTL', resolvePositiveIntegerEnv('SESSION_TTL', DEFAULT_SESSION_TTL));\n\nconst SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || '__session_id';\nconst SESSION_SECRET = resolveSessionSecret();\nconst SESSION_COOKIE_SECURE = resolveCookieSecure();\nconst SESSION_COOKIE_PATH = '/';\nconst SESSION_COOKIE_SAME_SITE = resolveCookieSameSite();\nconst SESSION_COOKIE_DOMAIN = process.env.SESSION_COOKIE_DOMAIN?.trim();\nconst SESSION_ENCRYPTION_ENABLED = resolveSessionEncryptionEnabled();\nconst SESSION_ENCRYPTION_KEY = resolveSessionEncryptionKey(SESSION_ENCRYPTION_ENABLED);\n\nexport const AUTH_ERROR_COOKIE_PREFIX = SESSION_COOKIE_NAME.replace('_session', '').replace('__', '');\n\nfunction resolveBooleanEnv(name: string, defaultValue: boolean): boolean {\n const value = process.env[name]?.trim().toLowerCase();\n if (!value) {\n return defaultValue;\n }\n\n if (['1', 'true', 'yes', 'on'].includes(value)) {\n return true;\n }\n\n if (['0', 'false', 'no', 'off'].includes(value)) {\n return false;\n }\n\n throw new Error(`${name} must be a boolean value.`);\n}\n\nfunction resolvePositiveIntegerEnv(name: string, defaultValue: number): number {\n const value = process.env[name]?.trim();\n if (!value) {\n return defaultValue;\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed <= 0) {\n throw new Error(`${name} must be a positive integer.`);\n }\n\n return parsed;\n}\n\nfunction resolveSessionSecret(): string {\n const secret = process.env.SESSION_SECRET?.trim();\n if (!secret || secret === DEFAULT_SESSION_SECRET) {\n if (spineRuntimeEnv.NODE_ENV === 'production') {\n throw new Error('SESSION_SECRET must be set to a unique random value in production.');\n }\n\n return DEFAULT_SESSION_SECRET;\n }\n\n if (secret.length < 32) {\n throw new Error('SESSION_SECRET must be at least 32 characters long.');\n }\n\n return secret;\n}\n\nfunction resolveCookieSecure(): boolean {\n return resolveBooleanEnv('SESSION_COOKIE_SECURE', spineRuntimeEnv.NODE_ENV === 'production');\n}\n\nfunction resolveCookieSameSite(): 'Lax' | 'Strict' | 'None' {\n const value = process.env.SESSION_COOKIE_SAME_SITE?.trim().toLowerCase();\n const sameSite = value === 'strict' ? 'Strict' : value === 'none' ? 'None' : 'Lax';\n\n if (sameSite === 'None' && !SESSION_COOKIE_SECURE) {\n throw new Error('SESSION_COOKIE_SAME_SITE=None requires SESSION_COOKIE_SECURE=true.');\n }\n\n return sameSite;\n}\n\nfunction resolveSessionEncryptionEnabled(): boolean {\n const enabled = resolveBooleanEnv('SESSION_ENCRYPTION', spineRuntimeEnv.NODE_ENV === 'production');\n if (spineRuntimeEnv.NODE_ENV === 'production' && !enabled) {\n throw new Error('SESSION_ENCRYPTION cannot be disabled in production.');\n }\n\n return enabled;\n}\n\nfunction decodeBase64Url(value: string): Buffer {\n const normalized = value.replace(/-/g, '+').replace(/_/g, '/');\n const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');\n return Buffer.from(padded, 'base64');\n}\n\nfunction resolveSessionEncryptionKey(enabled: boolean): Buffer | null {\n if (!enabled) {\n return null;\n }\n\n const value = process.env.SESSION_ENCRYPTION_KEY?.trim();\n if (!value) {\n throw new Error('SESSION_ENCRYPTION_KEY is required when session encryption is enabled.');\n }\n\n const key = /^[0-9a-f]{64}$/i.test(value)\n ? Buffer.from(value, 'hex')\n : decodeBase64Url(value);\n\n if (key.length !== 32) {\n throw new Error('SESSION_ENCRYPTION_KEY must decode to exactly 32 bytes. Generate one with: openssl rand -base64 32');\n }\n\n return key;\n}\n\ntype SessionCookieOptions = {\n maxAge: number;\n};\n\nfunction parseCookieHeader(cookieHeader: string | null): Record<string, string> {\n if (!cookieHeader) {\n return {};\n }\n\n return cookieHeader\n .split(';')\n .map((part) => part.trim())\n .filter(Boolean)\n .reduce<Record<string, string>>((cookies, part) => {\n const separatorIndex = part.indexOf('=');\n if (separatorIndex <= 0) {\n return cookies;\n }\n\n const name = part.slice(0, separatorIndex).trim();\n const value = part.slice(separatorIndex + 1).trim();\n if (!name) {\n return cookies;\n }\n\n cookies[name] = value;\n return cookies;\n }, {});\n}\n\nfunction createCookieSignature(value: string, secret: string): string {\n return createHmac('sha256', secret).update(value).digest('base64url');\n}\n\nfunction safelyCompareValues(left: string, right: string): boolean {\n const leftBuffer = Buffer.from(left);\n const rightBuffer = Buffer.from(right);\n\n if (leftBuffer.length !== rightBuffer.length) {\n return false;\n }\n\n return timingSafeEqual(leftBuffer, rightBuffer);\n}\n\nfunction signSessionId(sessionId: string): string {\n const signature = createCookieSignature(sessionId, SESSION_SECRET);\n return `${sessionId}.${signature}`;\n}\n\nfunction unsignSessionId(cookieValue: string | undefined): string | null {\n if (!cookieValue) {\n return null;\n }\n\n let decodedValue: string;\n try {\n decodedValue = decodeURIComponent(cookieValue);\n } catch {\n return null;\n }\n\n const separatorIndex = decodedValue.lastIndexOf('.');\n if (separatorIndex <= 0 || separatorIndex === decodedValue.length - 1) {\n return null;\n }\n\n const sessionId = decodedValue.slice(0, separatorIndex);\n const signature = decodedValue.slice(separatorIndex + 1);\n const expectedSignature = createCookieSignature(sessionId, SESSION_SECRET);\n\n return safelyCompareValues(signature, expectedSignature) ? sessionId : null;\n}\n\nfunction serializeSessionCookieValue(sessionId: string, options: SessionCookieOptions): string {\n const signedValue = encodeURIComponent(signSessionId(sessionId));\n const segments = [\n `${SESSION_COOKIE_NAME}=${signedValue}`,\n `Max-Age=${options.maxAge}`,\n `Path=${SESSION_COOKIE_PATH}`,\n `SameSite=${SESSION_COOKIE_SAME_SITE}`,\n 'HttpOnly',\n ];\n\n if (SESSION_COOKIE_DOMAIN) {\n segments.push(`Domain=${SESSION_COOKIE_DOMAIN}`);\n }\n\n if (SESSION_COOKIE_SECURE) {\n segments.push('Secure');\n }\n\n return segments.join('; ');\n}\n\nfunction serializeExpiredSessionCookie(): string {\n const segments = [\n `${SESSION_COOKIE_NAME}=`,\n 'Max-Age=0',\n `Path=${SESSION_COOKIE_PATH}`,\n `SameSite=${SESSION_COOKIE_SAME_SITE}`,\n 'HttpOnly',\n ];\n\n if (SESSION_COOKIE_DOMAIN) {\n segments.push(`Domain=${SESSION_COOKIE_DOMAIN}`);\n }\n\n if (SESSION_COOKIE_SECURE) {\n segments.push('Secure');\n }\n\n return segments.join('; ');\n}\n\nasync function getSessionIdFromRequest(request: Request): Promise<string | null> {\n const cookies = parseCookieHeader(request.headers.get('Cookie'));\n return unsignSessionId(cookies[SESSION_COOKIE_NAME]);\n}\n\nfunction getSessionIndexKeys(data: SessionData): string[] {\n const keys = new Set<string>();\n\n if (data.userId) {\n keys.add(REDIS_KEYS.sessionByUser(data.userId));\n }\n\n if (data.sid) {\n keys.add(REDIS_KEYS.sessionBySid(data.sid));\n }\n\n if (data.sessionState) {\n keys.add(REDIS_KEYS.sessionBySessionState(data.sessionState));\n }\n\n return Array.from(keys);\n}\n\nasync function registerSessionIndexes(sessionId: string, data: SessionData): Promise<void> {\n const indexKeys = getSessionIndexKeys(data);\n if (indexKeys.length === 0) {\n return;\n }\n\n const redisClient = getRedis();\n const pipeline = redisClient.pipeline();\n\n for (const indexKey of indexKeys) {\n pipeline.sadd(indexKey, sessionId);\n pipeline.expire(indexKey, SESSION_TTL);\n }\n\n await pipeline.exec();\n}\n\nasync function unregisterSessionIndexes(sessionId: string, data: SessionData): Promise<void> {\n const indexKeys = getSessionIndexKeys(data);\n if (indexKeys.length === 0) {\n return;\n }\n\n const redisClient = getRedis();\n const pipeline = redisClient.pipeline();\n\n for (const indexKey of indexKeys) {\n pipeline.srem(indexKey, sessionId);\n }\n\n await pipeline.exec();\n}\n\nfunction parseSessionData(sessionData: string | null): SessionData | null {\n if (!sessionData) {\n return null;\n }\n\n try {\n return JSON.parse(decodeSessionData(sessionData)) as SessionData;\n } catch (error) {\n logger.error('Failed to parse session data', error instanceof Error ? error : undefined);\n return null;\n }\n}\n\nfunction encodeSessionData(data: SessionData): string {\n const plaintext = JSON.stringify(data);\n if (!SESSION_ENCRYPTION_KEY) {\n return plaintext;\n }\n\n const iv = randomBytes(12);\n const cipher = createCipheriv('aes-256-gcm', SESSION_ENCRYPTION_KEY, iv);\n const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);\n const tag = cipher.getAuthTag();\n\n return [\n 'enc:v1',\n iv.toString('base64url'),\n tag.toString('base64url'),\n ciphertext.toString('base64url'),\n ].join(':');\n}\n\nfunction decodeSessionData(value: string): string {\n if (!value.startsWith('enc:v1:')) {\n if (SESSION_ENCRYPTION_ENABLED) {\n logger.warn('Reading legacy plaintext auth session payload while session encryption is enabled.');\n }\n\n return value;\n }\n\n if (!SESSION_ENCRYPTION_KEY) {\n throw new Error('Cannot decrypt auth session payload because SESSION_ENCRYPTION_KEY is not configured.');\n }\n\n const [, version, ivValue, tagValue, ciphertextValue] = value.split(':');\n if (version !== 'v1' || !ivValue || !tagValue || !ciphertextValue) {\n throw new Error('Invalid encrypted auth session payload format.');\n }\n\n const decipher = createDecipheriv('aes-256-gcm', SESSION_ENCRYPTION_KEY, Buffer.from(ivValue, 'base64url'));\n decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));\n\n return Buffer.concat([\n decipher.update(Buffer.from(ciphertextValue, 'base64url')),\n decipher.final(),\n ]).toString('utf8');\n}\n\nasync function readSessionData(sessionId: string, throwOnError = false): Promise<SessionData | null> {\n try {\n const sessionData = await getRedis().get(REDIS_KEYS.session(sessionId));\n if (!sessionData) {\n logger.debug('Session not found in Redis', { sessionId });\n return null;\n }\n\n await getRedis().expire(REDIS_KEYS.session(sessionId), SESSION_TTL);\n return throwOnError\n ? JSON.parse(decodeSessionData(sessionData)) as SessionData\n : parseSessionData(sessionData);\n } catch (error) {\n logger.error('Failed to read session from Redis', error instanceof Error ? error : undefined);\n if (throwOnError) throw error;\n return null;\n }\n}\n\nasync function writeSessionData(sessionId: string, data: SessionData): Promise<void> {\n try {\n const existingSessionData = parseSessionData(await getRedis().get(REDIS_KEYS.session(sessionId)));\n if (existingSessionData) {\n await unregisterSessionIndexes(sessionId, existingSessionData);\n }\n\n await getRedis().setex(REDIS_KEYS.session(sessionId), SESSION_TTL, encodeSessionData(data));\n await registerSessionIndexes(sessionId, data);\n logger.debug('Updated Redis session', { sessionId });\n } catch (error) {\n logger.error('Failed to update session in Redis', error instanceof Error ? error : undefined);\n throw error;\n }\n}\n\nasync function deleteSessionData(sessionId: string): Promise<void> {\n try {\n const existingSessionData = parseSessionData(await getRedis().get(REDIS_KEYS.session(sessionId)));\n if (existingSessionData) {\n await unregisterSessionIndexes(sessionId, existingSessionData);\n }\n\n await getRedis().del(REDIS_KEYS.session(sessionId));\n logger.debug('Deleted Redis session', { sessionId });\n } catch (error) {\n logger.error('Failed to delete session from Redis', error instanceof Error ? error : undefined);\n }\n}\n\nfunction mergeSessionData(existingData: SessionData, updates: Partial<SessionData>): SessionData {\n const nextData = { ...existingData } as Record<string, unknown>;\n\n Object.entries(updates).forEach(([key, value]) => {\n if (value === undefined) {\n delete nextData[key];\n return;\n }\n\n nextData[key] = value;\n });\n\n return nextData as SessionData;\n}\n\nasync function getSessionIdsFromIndex(indexKey: string): Promise<string[]> {\n const sessionIds = await getRedis().smembers(indexKey);\n const activeSessionIds: string[] = [];\n const staleSessionIds: string[] = [];\n\n for (const sessionId of sessionIds) {\n const exists = await getRedis().exists(REDIS_KEYS.session(sessionId));\n if (exists) {\n activeSessionIds.push(sessionId);\n } else {\n staleSessionIds.push(sessionId);\n }\n }\n\n if (staleSessionIds.length > 0) {\n await getRedis().srem(indexKey, ...staleSessionIds);\n }\n\n return activeSessionIds;\n}\n\nfunction toAuthSessionSummary(\n sessionId: string,\n session: SessionData,\n currentSessionId?: string | null\n): AuthSessionSummary {\n return {\n sessionId,\n userId: session.userId,\n email: session.user?.email,\n name: session.user?.name,\n issuer: session.issuer,\n sid: session.sid,\n sessionState: session.sessionState,\n clientId: session.clientId,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n lastActivity: session.lastActivity,\n expiresAt: session.expiresAt,\n isCurrent: currentSessionId ? sessionId === currentSessionId : undefined,\n };\n}\n\nasync function listSessionDataByIds(sessionIds: string[]): Promise<Array<{ sessionId: string; data: SessionData }>> {\n const sessions: Array<{ sessionId: string; data: SessionData }> = [];\n\n for (const sessionId of sessionIds) {\n const data = await readSessionData(sessionId);\n if (data) {\n sessions.push({ sessionId, data });\n }\n }\n\n return sessions;\n}\n\nfunction getRequestIpAddress(request: Request): string | undefined {\n const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();\n return (\n forwardedFor ||\n request.headers.get('cf-connecting-ip')?.trim() ||\n request.headers.get('x-real-ip')?.trim() ||\n undefined\n );\n}\n\n// ============================================================================\n// Session Helper Functions\n// ============================================================================\n\nexport async function createAuthSession(\n request: Request,\n sessionData: Partial<SessionData>\n): Promise<Headers> {\n const currentSessionId = await getSessionIdFromRequest(request);\n const nextSessionId = crypto.randomUUID();\n const nextSessionData = mergeSessionData({}, {\n ...sessionData,\n sessionId: nextSessionId,\n ipAddress: sessionData.ipAddress ?? getRequestIpAddress(request),\n userAgent: sessionData.userAgent ?? request.headers.get('user-agent') ?? undefined,\n });\n\n logger.debug('Creating auth session', { dataKeys: Object.keys(sessionData) });\n\n if (currentSessionId) {\n await deleteSessionData(currentSessionId);\n }\n\n await writeSessionData(nextSessionId, nextSessionData);\n\n return new Headers({\n 'Set-Cookie': serializeSessionCookieValue(nextSessionId, { maxAge: SESSION_TTL }),\n });\n}\n\nexport async function getAuthSession(\n request: Request,\n options: { throwOnError?: boolean } = {},\n): Promise<SessionData> {\n const sessionId = await getSessionIdFromRequest(request);\n if (!sessionId) {\n return {};\n }\n\n const session = await readSessionData(sessionId, options.throwOnError);\n if (!session) {\n return {};\n }\n\n return {\n sessionId,\n userId: session.userId,\n accessToken: session.accessToken,\n refreshToken: session.refreshToken,\n idToken: session.idToken,\n expiresAt: session.expiresAt,\n issuer: session.issuer,\n sid: session.sid,\n sessionState: session.sessionState,\n clientId: session.clientId,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n user: session.user,\n createdAt: session.createdAt,\n lastActivity: session.lastActivity,\n };\n}\n\n/** Internal refresh coordination. The owner is never derived from client input. */\nexport async function acquireAuthSessionRefreshLock(\n sessionId: string,\n owner: string,\n ttlMs: number,\n): Promise<boolean> {\n return await getRedis().set(REDIS_KEYS.sessionRefreshLock(sessionId), owner, 'PX', ttlMs, 'NX') === 'OK';\n}\n\nexport async function releaseAuthSessionRefreshLock(sessionId: string, owner: string): Promise<void> {\n await getRedis().eval(`\n if redis.call('GET', KEYS[1]) == ARGV[1] then\n return redis.call('DEL', KEYS[1])\n end\n return 0\n `, 1, REDIS_KEYS.sessionRefreshLock(sessionId), owner);\n}\n\ntype RefreshedSessionFields = Pick<SessionData, 'accessToken' | 'refreshToken' | 'idToken' | 'expiresAt' | 'lastActivity'>;\n\nexport function hasSameAuthTokenGeneration(current: SessionData, expected: SessionData): boolean {\n return current.accessToken === expected.accessToken &&\n current.refreshToken === expected.refreshToken &&\n current.idToken === expected.idToken &&\n current.expiresAt === expected.expiresAt;\n}\n\n/** Delete only the rejected generation while its refresh lease is still owned. */\nexport async function invalidateAuthSessionRefreshGeneration(\n expected: SessionData & { sessionId: string },\n owner: string,\n): Promise<'invalidated' | 'missing' | 'changed' | 'lock-lost'> {\n const sessionKey = REDIS_KEYS.session(expected.sessionId);\n for (let attempt = 0; attempt < 8; attempt += 1) {\n const snapshot = await getRedis().get(sessionKey);\n if (!snapshot) return 'missing';\n const current = JSON.parse(decodeSessionData(snapshot)) as SessionData;\n if (!hasSameAuthTokenGeneration(current, expected)) return 'changed';\n const indexes = getSessionIndexKeys(current);\n const result = Number(await getRedis().eval(`\n if redis.call('GET', KEYS[2]) ~= ARGV[1] then return -1 end\n local current = redis.call('GET', KEYS[1])\n if not current then return -2 end\n if current ~= ARGV[2] then return 0 end\n redis.call('DEL', KEYS[1])\n for i = 3, #KEYS do redis.call('SREM', KEYS[i], ARGV[3]) end\n return 1\n `, 2 + indexes.length, sessionKey, REDIS_KEYS.sessionRefreshLock(expected.sessionId), ...indexes,\n owner, snapshot, expected.sessionId));\n if (result === 1) return 'invalidated';\n if (result === -1) return 'lock-lost';\n if (result === -2) return 'missing';\n }\n throw new Error('Session changed repeatedly during refresh invalidation');\n}\n\n/**\n * Merge tokens with the latest session and atomically compare the encrypted\n * snapshot and lease owner. Redis never needs the session encryption key.\n * A concurrent logout or replacement token generation cannot be overwritten.\n */\nexport async function saveRefreshedAuthSession(\n expected: SessionData & { sessionId: string },\n owner: string,\n updates: RefreshedSessionFields,\n): Promise<'updated' | 'missing' | 'changed' | 'lock-lost'> {\n const sessionKey = REDIS_KEYS.session(expected.sessionId);\n for (let attempt = 0; attempt < 8; attempt += 1) {\n const snapshot = await getRedis().get(sessionKey);\n if (!snapshot) return 'missing';\n const current = JSON.parse(decodeSessionData(snapshot)) as SessionData;\n if (!hasSameAuthTokenGeneration(current, expected)) return 'changed';\n\n const next = mergeSessionData(current, updates);\n const indexes = getSessionIndexKeys(next);\n const result = Number(await getRedis().eval(`\n if redis.call('GET', KEYS[2]) ~= ARGV[1] then return -1 end\n local current = redis.call('GET', KEYS[1])\n if not current then return -2 end\n if current ~= ARGV[2] then return 0 end\n redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[4])\n for i = 3, #KEYS do\n redis.call('SADD', KEYS[i], ARGV[5])\n redis.call('EXPIRE', KEYS[i], ARGV[4])\n end\n return 1\n `, 2 + indexes.length, sessionKey, REDIS_KEYS.sessionRefreshLock(expected.sessionId), ...indexes,\n owner, snapshot, encodeSessionData(next), SESSION_TTL, expected.sessionId));\n if (result === 1) return 'updated';\n if (result === -1) return 'lock-lost';\n if (result === -2) return 'missing';\n // An unrelated session field changed. Read it again and preserve it.\n }\n throw new Error('Session changed repeatedly during token refresh');\n}\n\nexport async function updateAuthSession(\n request: Request,\n updates: Partial<SessionData>\n): Promise<Headers> {\n const currentSessionId = await getSessionIdFromRequest(request);\n const currentSessionData = currentSessionId\n ? (await readSessionData(currentSessionId)) ?? {}\n : {};\n const nextSessionId = currentSessionId ?? crypto.randomUUID();\n const nextSessionData = mergeSessionData(currentSessionData, {\n ...updates,\n sessionId: nextSessionId,\n });\n\n await writeSessionData(nextSessionId, nextSessionData);\n\n return new Headers({\n 'Set-Cookie': serializeSessionCookieValue(nextSessionId, { maxAge: SESSION_TTL }),\n });\n}\n\nexport async function destroyAuthSession(request: Request): Promise<Headers> {\n const sessionId = await getSessionIdFromRequest(request);\n if (sessionId) {\n await deleteSessionData(sessionId);\n }\n\n return clearAuthSessionCookie();\n}\n\n/** Cookie cleanup after a generation was already invalidated atomically. */\nexport function clearAuthSessionCookie(): Headers {\n return new Headers({ 'Set-Cookie': serializeExpiredSessionCookie() });\n}\n\nexport async function getCurrentAuthSessionId(request: Request): Promise<string | null> {\n return await getSessionIdFromRequest(request);\n}\n\nexport async function getAuthSessionById(sessionId: string): Promise<SessionData | null> {\n const session = await readSessionData(sessionId);\n return session ? { ...session, sessionId } : null;\n}\n\nexport async function listAuthSessionDataForUser(\n userId: string\n): Promise<Array<{ sessionId: string; data: SessionData }>> {\n const sessionIds = await getSessionIdsFromIndex(REDIS_KEYS.sessionByUser(userId));\n return await listSessionDataByIds(sessionIds);\n}\n\nexport async function listAuthSessionsForUser(\n userId: string,\n currentSessionId?: string | null\n): Promise<AuthSessionSummary[]> {\n const sessions = await listAuthSessionDataForUser(userId);\n\n return sessions\n .map(({ sessionId, data }) => toAuthSessionSummary(sessionId, data, currentSessionId))\n .sort((left, right) => (right.lastActivity ?? right.createdAt ?? 0) - (left.lastActivity ?? left.createdAt ?? 0));\n}\n\nexport async function listCurrentUserAuthSessions(request: Request): Promise<AuthSessionSummary[]> {\n const currentSession = await getAuthSession(request);\n if (!currentSession.userId) {\n return [];\n }\n\n return await listAuthSessionsForUser(currentSession.userId, currentSession.sessionId);\n}\n\nexport async function destroyAuthSessionById(sessionId: string): Promise<boolean> {\n const session = await readSessionData(sessionId);\n if (!session) {\n return false;\n }\n\n await deleteSessionData(sessionId);\n return true;\n}\n\nexport async function destroyAuthSessionsForUser(userId: string): Promise<number> {\n const sessionIds = await getSessionIdsFromIndex(REDIS_KEYS.sessionByUser(userId));\n let destroyedSessions = 0;\n\n for (const sessionId of sessionIds) {\n if (await destroyAuthSessionById(sessionId)) {\n destroyedSessions += 1;\n }\n }\n\n return destroyedSessions;\n}\n\nexport async function destroyAuthSessionsBySid(sid: string): Promise<number> {\n const sessionIds = await getSessionIdsFromIndex(REDIS_KEYS.sessionBySid(sid));\n let destroyedSessions = 0;\n\n for (const sessionId of sessionIds) {\n if (await destroyAuthSessionById(sessionId)) {\n destroyedSessions += 1;\n }\n }\n\n return destroyedSessions;\n}\n\nexport async function destroyAuthSessionsBySessionState(sessionState: string): Promise<number> {\n const sessionIds = await getSessionIdsFromIndex(REDIS_KEYS.sessionBySessionState(sessionState));\n let destroyedSessions = 0;\n\n for (const sessionId of sessionIds) {\n if (await destroyAuthSessionById(sessionId)) {\n destroyedSessions += 1;\n }\n }\n\n return destroyedSessions;\n}\n\nexport async function destroyAuthSessionsByIdentitySession(params: {\n sid?: string;\n sessionState?: string;\n userId?: string;\n}): Promise<number> {\n if (params.sid) {\n return await destroyAuthSessionsBySid(params.sid);\n }\n\n if (params.sessionState) {\n return await destroyAuthSessionsBySessionState(params.sessionState);\n }\n\n if (params.userId) {\n return await destroyAuthSessionsForUser(params.userId);\n }\n\n return 0;\n}\n\nexport async function requireAuthSession(request: Request): Promise<SessionData> {\n const sessionData = await getAuthSession(request);\n\n if (!sessionData.userId || !sessionData.user || !sessionData.accessToken || isSessionDataExpired(sessionData)) {\n throw new Response('Unauthorized', { status: 401 });\n }\n\n return sessionData;\n}\n\nfunction isSessionDataExpired(sessionData: SessionData): boolean {\n const now = Date.now();\n if (\n typeof sessionData.expiresAt === 'number' &&\n (!Number.isFinite(sessionData.expiresAt) || now >= sessionData.expiresAt)\n ) {\n return true;\n }\n\n const jwtExpiry = getTokenExpiry(sessionData);\n return jwtExpiry !== null && now >= jwtExpiry;\n}\n\nexport async function isSessionValid(request: Request): Promise<boolean> {\n try {\n const sessionData = await getAuthSession(request);\n\n if (!sessionData.user || !sessionData.accessToken || isSessionDataExpired(sessionData)) {\n return false;\n }\n\n return true;\n } catch {\n return false;\n }\n}\n\n// ============================================================================\n// OAuth State Management\n// ============================================================================\n\nexport async function createOAuthState(state: OAuthState): Promise<string> {\n const stateId = crypto.randomUUID();\n const key = REDIS_KEYS.oauthState(stateId);\n\n await getRedis().setex(key, OAUTH_STATE_TTL_SECONDS, JSON.stringify(state));\n\n logger.debug('OAuth state created', { hasStateId: true, ttl: OAUTH_STATE_TTL_SECONDS });\n\n return stateId;\n}\n\nexport async function getOAuthState(\n stateId: string,\n options?: { readonly throwOnMalformed?: boolean },\n): Promise<OAuthState | null> {\n const key = REDIS_KEYS.oauthState(stateId);\n const data = await getRedis().get(key);\n\n if (!data) {\n logger.warn('OAuth state not found or expired', { hasStateId: true });\n return null;\n }\n\n try {\n return JSON.parse(data);\n } catch (error) {\n logger.error('Failed to parse OAuth state', undefined, {\n errorType: error instanceof Error ? error.name : 'UnknownError',\n });\n if (options?.throwOnMalformed) {\n throw new Error('OAuth state storage failure');\n }\n return null;\n }\n}\n\nconst DELETE_OAUTH_STATE_FOR_CALLBACK_SCRIPT = `\nlocal value = redis.call('GET', KEYS[1])\nif not value then\n return 0\nend\nlocal ok, record = pcall(cjson.decode, value)\nif not ok or type(record) ~= 'table' or record.state ~= ARGV[1] then\n return 0\nend\nreturn redis.call('DEL', KEYS[1])\n`;\n\nexport async function deleteOAuthState(stateId: string, expectedState?: string): Promise<void> {\n const key = REDIS_KEYS.oauthState(stateId);\n if (expectedState === undefined) {\n await getRedis().del(key);\n } else {\n // Cookie IDs are opaque pointers; validate ownership atomically before DEL.\n await getRedis().eval(DELETE_OAUTH_STATE_FOR_CALLBACK_SCRIPT, 1, key, expectedState);\n }\n logger.debug('OAuth state deleted', { hasStateId: true });\n}\n\nexport interface OAuthRecoveryRecord {\n state: string;\n returnUrl: string;\n createdAt: number;\n}\n\nconst CONSUME_OAUTH_RECOVERY_RECORD_SCRIPT = `\nlocal value = redis.call('GET', KEYS[1])\nif not value then\n return nil\nend\nlocal ok, record = pcall(cjson.decode, value)\nif not ok or type(record) ~= 'table' or type(record.state) ~= 'string' then\n return '__invalid__'\nend\nif record.state ~= ARGV[1] then\n return '__mismatch__'\nend\nredis.call('DEL', KEYS[1])\nreturn value\n`;\n\n/**\n * Store a short-lived recovery record under a separate opaque ticket.\n * The ticket is returned to the caller and is never logged or embedded with\n * callback state, return URLs, or provider data.\n */\nexport async function createOAuthRecoveryRecord(\n record: OAuthRecoveryRecord,\n): Promise<string> {\n const ticket = randomBytes(32).toString('base64url');\n const key = REDIS_KEYS.oauthRecovery(ticket);\n\n await getRedis().setex(\n key,\n OAUTH_RECOVERY_TTL_SECONDS,\n JSON.stringify(record),\n );\n\n logger.debug('OAuth recovery record created', {\n hasTicket: true,\n ttl: OAUTH_RECOVERY_TTL_SECONDS,\n });\n\n return ticket;\n}\n\n/**\n * Atomically consume a recovery record. The Redis script compares the exact\n * expected state before DEL, so mismatched callbacks leave the ticket intact\n * and concurrent exact-state requests can receive a record at most once.\n */\nexport async function consumeOAuthRecoveryRecord(\n ticket: string,\n expectedState: string,\n): Promise<OAuthRecoveryRecord | null> {\n const key = REDIS_KEYS.oauthRecovery(ticket);\n const value = await getRedis().eval(\n CONSUME_OAUTH_RECOVERY_RECORD_SCRIPT,\n 1,\n key,\n expectedState,\n );\n\n if (value === '__mismatch__' || value === null || value === undefined) {\n return null;\n }\n\n if (value === '__invalid__') {\n logger.error('OAuth recovery record failed atomic validation', undefined, {\n errorType: 'InvalidRecoveryRecord',\n });\n throw new Error('OAuth recovery storage failure');\n }\n\n if (typeof value !== 'string') {\n throw new Error('OAuth recovery storage failure');\n }\n\n try {\n return JSON.parse(value) as OAuthRecoveryRecord;\n } catch (error) {\n logger.error('Failed to parse OAuth recovery record', undefined, {\n errorType: error instanceof Error ? error.name : 'UnknownError',\n });\n throw new Error('OAuth recovery storage failure');\n }\n}\n\nexport async function deleteOAuthRecoveryRecord(ticket: string): Promise<void> {\n await getRedis().del(REDIS_KEYS.oauthRecovery(ticket));\n logger.debug('OAuth recovery record deleted', { hasTicket: true });\n}\n\nexport async function cleanupExpiredOAuthStates(): Promise<number> {\n const pattern = REDIS_KEYS.oauthState('*');\n const keys = await getRedis().keys(pattern);\n\n let cleaned = 0;\n for (const key of keys) {\n const ttl = await getRedis().ttl(key);\n if (ttl === -1) {\n await getRedis().del(key);\n cleaned++;\n }\n }\n\n logger.info('OAuth state cleanup completed', { cleaned, total: keys.length });\n return cleaned;\n}\n\n// ============================================================================\n// Cleanup\n// ============================================================================\n\nexport async function closeRedisConnection(): Promise<void> {\n if (redis) {\n await redis.quit();\n redis = null;\n logger.info('Redis connection closed');\n }\n}\n"],"mappings":"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"}