247 lines
7.1 KiB
TypeScript
247 lines
7.1 KiB
TypeScript
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<typeof useSessionStore>) =>
|
|
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);
|
|
};
|