fix: session expiry during usage and also login error message

This commit is contained in:
Nathnael
2026-07-07 08:52:35 +00:00
parent ff0fa6bb9e
commit 63f638d1bb
9 changed files with 340 additions and 58 deletions

View File

@@ -16,6 +16,10 @@ import {
setCookie,
} from "./cookies";
import { applyTokens } from "./http";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
} from "./refreshScheduler";
import type { AuthTokens, AuthUser } from "./types";
interface LoginPayload {
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
void bootstrap();
}, []);
// Keep the server session alive while a user is logged in. Runs after
// login, MFA verification, and page-reload bootstrap alike.
useEffect(() => {
if (!user) {
stopTokenRefreshScheduler();
return;
}
startTokenRefreshScheduler();
return stopTokenRefreshScheduler;
}, [user]);
const value = useMemo<AuthContextValue>(
() => ({
user,

View File

@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
};
/**
* Single-flight token refresh: concurrent callers (the 401 interceptor and
* the proactive scheduler) share one in-flight request so the refresh token
* is only rotated once. Throws if no refresh token is stored or the server
* rejects it — callers decide how to end the session.
*/
const refreshSessionTokens = async (): Promise<AuthTokens> => {
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) {
throw new Error("missing refresh token");
}
refreshPromise ??= api
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
return tokens;
};
api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE);
@@ -65,8 +89,7 @@ api.interceptors.response.use(
return Promise.reject(error);
}
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) {
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
clearSessionCookies();
return Promise.reject(error);
}
@@ -74,15 +97,7 @@ api.interceptors.response.use(
originalRequest._retry = true;
try {
refreshPromise ??= api
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
const tokens = await refreshSessionTokens();
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`,
@@ -97,4 +112,4 @@ api.interceptors.response.use(
},
);
export { api, applyTokens };
export { api, applyTokens, refreshSessionTokens };

View File

@@ -0,0 +1,82 @@
import { isAxiosError } from "axios";
import {
REFRESH_TOKEN_COOKIE,
clearSessionCookies,
getCookie,
} from "./cookies";
import { refreshSessionTokens } from "./http";
/**
* Proactively refreshes the token pair on a fixed cadence so the server-side
* session (a sliding 1-hour window, extended only by /auth/refresh-token) is
* kept alive while the app is open. The 401 interceptor in http.ts remains
* the reactive fallback; both share the same single-flight refresh call.
*
* The interval MUST stay well under the server session window (60 min).
*/
const DEFAULT_INTERVAL_MINUTES = 10;
const getIntervalMs = () => {
const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
return (
(Number.isFinite(minutes) && minutes > 0
? minutes
: DEFAULT_INTERVAL_MINUTES) * 60_000
);
};
let timerId: number | null = null;
let lastRefreshAt = 0;
const refreshNow = async () => {
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
// Logged out elsewhere; nothing to keep alive.
stopTokenRefreshScheduler();
return;
}
try {
await refreshSessionTokens();
lastRefreshAt = Date.now();
} catch (error) {
// Network hiccups are retried on the next tick; only an explicit server
// rejection means the session is dead.
if (isAxiosError(error) && error.response) {
stopTokenRefreshScheduler();
clearSessionCookies();
window.location.replace("/auth");
}
}
};
/**
* Browsers freeze timers in background tabs — a tab waking up past its
* refresh deadline refreshes immediately instead of waiting a full interval.
*/
const onVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
if (Date.now() - lastRefreshAt >= getIntervalMs()) {
void refreshNow();
}
};
export const startTokenRefreshScheduler = () => {
stopTokenRefreshScheduler();
// Token age is unknown here (fresh login vs. hours-old page reload), so
// refresh right away to extend the session window from "now".
lastRefreshAt = 0;
void refreshNow();
timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
document.addEventListener("visibilitychange", onVisibilityChange);
};
export const stopTokenRefreshScheduler = () => {
if (timerId !== null) {
window.clearInterval(timerId);
timerId = null;
}
document.removeEventListener("visibilitychange", onVisibilityChange);
};