import router from "../router"; import { useAuthStore } from "../store/auth"; import { useSessionStore } from "../store/session"; import { isTauriRuntime } from "../runtime"; import { getToken, setToken } from "../utils/auth"; import { extendToken, logoutSession } from "../api/authClient"; import { parseJwtExp } from "./jwt"; export const IDLE_TIMEOUT_MINUTES = 30; export const TIMEOUT_WARNING_SECONDS = 60; export const EXTEND_EARLY_SECONDS = 120; export const EXTEND_MIN_INTERVAL_SECONDS = 60; export const LOGOUT_REASON_TIMEOUT = "timeout"; export const LOGOUT_REASON_MANUAL = "manual"; export const LOGOUT_REASON_AUTH_EXPIRED = "auth_expired"; const LOGOUT_REASON_STORAGE_KEY = "ctms_logout_reason"; type BroadcastMessage = | { type: "ACTIVE"; at: number } | { type: "TOKEN_UPDATED"; token: string } | { type: "LOGOUT"; reason?: string }; let idleTimer: number | null = null; let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null = null; let initialized = false; const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null; const shouldEnforceIdleTimeout = () => !isTauriRuntime(); const getTimeoutAt = (session: ReturnType) => session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000; const reconcileSessionState = (now: number = Date.now()) => { const session = useSessionStore(); if (!shouldEnforceIdleTimeout()) { session.clearTimeoutWarning(); return true; } if (now >= getTimeoutAt(session)) { void forceLogout(LOGOUT_REASON_TIMEOUT); return false; } return true; }; const broadcast = (message: BroadcastMessage) => { if (channel) { channel.postMessage(message); } if (message.type === "TOKEN_UPDATED") return; try { localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() })); } catch { /* ignore */ } }; const handleBroadcast = (message: BroadcastMessage) => { const session = useSessionStore(); const auth = useAuthStore(); if (message.type === "ACTIVE") { session.recordUserActivity(message.at); scheduleIdleCheck(); } if (message.type === "TOKEN_UPDATED") { auth.setToken(message.token); void setToken(message.token); session.resetActivity(); } if (message.type === "LOGOUT") { void performLogout(message.reason); } }; const scheduleIdleCheck = () => { if (idleTimer) { window.clearTimeout(idleTimer); } const session = useSessionStore(); if (!shouldEnforceIdleTimeout()) { session.clearTimeoutWarning(); return; } const now = Date.now(); const timeoutAt = getTimeoutAt(session); const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000; if (timeoutAt <= now) { checkIdle(); return; } if (warningAt <= now) { session.showTimeoutWarning(timeoutAt); idleTimer = window.setTimeout(checkIdle, timeoutAt - now); return; } session.clearTimeoutWarning(); idleTimer = window.setTimeout(checkIdle, warningAt - now); }; const checkIdle = () => { if (!reconcileSessionState(Date.now())) return; scheduleIdleCheck(); }; export const initSessionManager = () => { if (initialized) return; initialized = true; const events = ["mousemove", "mousedown", "keydown", "touchstart", "scroll"]; let lastSignal = 0; const onUserActivity = () => { const now = Date.now(); if (now - lastSignal < 1000) return; lastSignal = now; markUserActive(now); }; events.forEach((event) => window.addEventListener(event, onUserActivity, { passive: true })); window.addEventListener("focus", () => { checkIdle(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) { checkIdle(); } }); if (channel) { channel.onmessage = (event) => handleBroadcast(event.data as BroadcastMessage); } window.addEventListener("storage", (event) => { if (event.key !== "ctms_auth_broadcast" || !event.newValue) return; try { const message = JSON.parse(event.newValue) as BroadcastMessage; handleBroadcast(message); } catch { /* ignore */ } }); scheduleIdleCheck(); startTokenKeepAlive(); }; export const markUserActive = (at: number = Date.now()) => { const session = useSessionStore(); if (!reconcileSessionState(at)) return; session.recordUserActivity(at); broadcast({ type: "ACTIVE", at }); scheduleIdleCheck(); }; const setLogoutReason = (reason?: string) => { try { if (!reason) { sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY); return; } sessionStorage.setItem(LOGOUT_REASON_STORAGE_KEY, reason); } catch { /* ignore */ } }; export const consumeLogoutReason = () => { try { const reason = sessionStorage.getItem(LOGOUT_REASON_STORAGE_KEY); sessionStorage.removeItem(LOGOUT_REASON_STORAGE_KEY); return reason; } catch { return null; } }; const performLogout = async (reason?: string) => { setLogoutReason(reason); const auth = useAuthStore(); const session = useSessionStore(); const token = getToken(); if (token) { await logoutSession(token).catch(() => undefined); } const logoutPromise = auth.logout(); session.resetActivity(); router.replace("/login"); await logoutPromise; }; export const forceLogout = async (reason?: string) => { await performLogout(reason); broadcast({ type: "LOGOUT", reason }); }; const updateToken = async (token: string) => { const auth = useAuthStore(); auth.setToken(token); await setToken(token); broadcast({ type: "TOKEN_UPDATED", token }); }; export const extendAccessToken = async (reason: "early" | "response-401") => { const session = useSessionStore(); const token = getToken(); if (!token) return { token: null, authFailed: false }; if (reason === "early") { const now = Date.now(); if (now - session.lastExtendAt < EXTEND_MIN_INTERVAL_SECONDS * 1000) { return { token: null, authFailed: false }; } } if (extendPromise) return extendPromise; extendPromise = (async () => { let authFailed = false; try { const { data } = await extendToken(token); session.setLastExtendAt(Date.now()); await updateToken(data.accessToken); return { token: data.accessToken, authFailed: false }; } catch (err: any) { const status = err?.response?.status; if (status === 401) { await forceLogout(LOGOUT_REASON_AUTH_EXPIRED); authFailed = true; } else if (status === 403) { await forceLogout(LOGOUT_REASON_AUTH_EXPIRED); authFailed = true; } return { token: null, authFailed }; } finally { extendPromise = null; } })(); return extendPromise; }; export const startTokenKeepAlive = () => { window.setInterval(() => { const token = getToken(); if (!token) return; const session = useSessionStore(); const expAt = parseJwtExp(token); if (!expAt) return; const remaining = expAt - Date.now(); const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; const active = Date.now() - session.lastUserActiveAt < timeoutMs; if ((!shouldEnforceIdleTimeout() || active) && remaining < EXTEND_EARLY_SECONDS * 1000) { void extendAccessToken("early"); } }, 10000); };