Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions frontend/lib/__tests__/session-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Tests for graceful session expiry handling (Issue #1024).
*
* Core requirement: 10 concurrent requests that all get 401 should trigger
* exactly 1 refresh call.
*/

// Mock localStorage
const store: Record<string, string> = {};
const localStorageMock = {
getItem: jest.fn((key: string) => store[key] ?? null),
setItem: jest.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: jest.fn((key: string) => {
delete store[key];
}),
clear: jest.fn(() => {
Object.keys(store).forEach((key) => delete store[key]);
}),
get length() {
return Object.keys(store).length;
},
key: jest.fn((index: number) => Object.keys(store)[index] ?? null),
};

Object.defineProperty(window, "localStorage", { value: localStorageMock });

// We need to isolate the module so the refreshPromise singleton is fresh
// and we can spy on fetch calls.
// eslint-disable-next-line @typescript-eslint/no-require-imports
let apiClient: typeof import("../api-client");

beforeEach(() => {
jest.resetModules();
localStorageMock.clear();
// Clear the module cache so the refreshPromise singleton resets
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
apiClient = require("../api-client");
});
// Seed a valid refresh token so the refresh path can execute
store["refresh-token"] = "fake-refresh-token";
store["auth-token"] = "expired-access-token";
});

describe("Concurrent 401 → single refresh (Issue #1024)", () => {
it("triggers exactly one refresh when 10 parallel requests get 401", async () => {
// Reload inside isolatedModules to get a fresh singleton
let freshApiClient: typeof import("../api-client");
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
freshApiClient = require("../api-client");
});

let refreshCallCount = 0;

// Mock fetch: first call to any endpoint returns 401,
// the refresh call returns 200 with a new token,
// and retried calls return 200.
(global as any).fetch = jest.fn((url: string) => {
if (url.includes("/auth/refresh")) {
refreshCallCount += 1;
return Promise.resolve(
new Response(
JSON.stringify({ access_token: "new-access-token" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}
// Non-refresh endpoints: succeed on the second attempt (after refresh)
return Promise.resolve(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
}) as jest.Mock;

// Fire 10 concurrent requests — each will get a 401, triggering the
// refresh path. The refreshPromise singleton should deduplicate them.
const promises = Array.from({ length: 10 }, (_, i) =>
freshApiClient!.request(`/api/documents/${i}`),
);

await Promise.all(promises);

// Assert: exactly 1 refresh call despite 10 concurrent 401s
expect(refreshCallCount).toBe(1);
});

it("clears session and redirects when refresh fails", async () => {
let freshApiClient: typeof import("../api-client");
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
freshApiClient = require("../api-client");
});

// Override window.location for the redirect assertion
const originalLocation = window.location;
// @ts-expect-error — partial mock of Location
delete (window as any).location;
(window as any).location = { href: "" };

(global as any).fetch = jest.fn((url: string) => {
if (url.includes("/auth/refresh")) {
return Promise.resolve(
new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 401,
}),
);
}
return Promise.resolve(
new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
}),
);
}) as jest.Mock;

try {
await freshApiClient!.request("/api/documents/1");
} catch {
// Expected — api-client throws after refresh failure
}

// Should have cleared localStorage tokens
expect(store["auth-token"]).toBeUndefined();
expect(store["refresh-token"]).toBeUndefined();
// Should have redirected to login preserving the path
expect(window.location.href).toContain("/login?redirect=");

// Restore
(window as any).location = originalLocation;
});
});
12 changes: 11 additions & 1 deletion frontend/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,21 @@ export function clearSession(): void {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
// Signal other tabs to also redirect to login
window.localStorage.setItem("logout-event", Date.now().toString());
}

/**
* Preserve the current path (including locale) as the post-login destination,
* then redirect to login. The login page's resolvePostLoginPath reads the
* `?redirect=` param so the user lands back where they were after signing in.
*/
function redirectToLogin(): void {
if (typeof window === "undefined") return;
window.location.href = "/login";
const currentPath = window.location.pathname + window.location.search;
const params = new URLSearchParams();
params.set("redirect", currentPath);
window.location.href = `/login?${params.toString()}`;
}

// ── Refresh logic ───────────────────────────────────────────────────────────
Expand Down
112 changes: 112 additions & 0 deletions frontend/lib/session-expiry-warning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"use client";

import { useEffect, useRef, useCallback } from "react";

const ACCESS_TOKEN_KEY = "auth-token";

// ── JWT expiry helper ───────────────────────────────────────────────────────

function getTokenExpiryMs(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
if (!payload.exp) return null;
// exp is in seconds; return ms until expiry
return payload.exp * 1000 - Date.now();
} catch {
return null;
}
}

// ── Cross-tab logout sync ───────────────────────────────────────────────────

/**
* Listen for the `logout-event` localStorage key written by clearSession()
* in other tabs and redirect to login. Call once on app bootstrap.
*/
export function initCrossTabLogoutSync(): () => void {
if (typeof window === "undefined") return () => {};

function handler(event: StorageEvent) {
if (event.key === "logout-event" && event.newValue) {
window.location.href = "/login";
}
}

window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
}

// ── Session expiry warning hook ─────────────────────────────────────────────

const WARNING_BEFORE_MS = 5 * 60 * 1000; // warn 5 minutes before expiry

interface UseSessionExpiryWarningOptions {
/** Called when the user clicks "Stay signed in" — should trigger a refresh. */
onRefresh: () => Promise<void>;
/** Whether the user is currently authenticated (skip if false). */
enabled?: boolean;
}

/**
* Polls the access token's `exp` claim and prompts the user with a
* "Stay signed in?" dialog a few minutes before the session expires.
*
* If the user confirms, calls `onRefresh`. If they dismiss or the token
* expires, the consumer should handle the redirect (the api-client's 401
* interceptor will do this automatically on the next request).
*/
export function useSessionExpiryWarning({
onRefresh,
enabled = true,
}: UseSessionExpiryWarningOptions) {
const warningShownRef = useRef(false);
const promptRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const clearTimer = useCallback(() => {
if (promptRef.current !== null) {
clearTimeout(promptRef.current);
promptRef.current = null;
}
}, []);

useEffect(() => {
if (!enabled || typeof window === "undefined") return;

function schedulePrompt() {
clearTimer();

const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
if (!token) return;

const remainingMs = getTokenExpiryMs(token);
if (remainingMs === null || remainingMs <= 0) return;

const delayMs = Math.max(remainingMs - WARNING_BEFORE_MS, 0);

promptRef.current = setTimeout(() => {
if (warningShownRef.current) return;
warningShownRef.current = true;

// Simple confirm dialog — can be replaced with a custom UI later
const staySignedIn = window.confirm(
"Your session is about to expire. Stay signed in?",
);

if (staySignedIn) {
onRefresh()
.then(() => {
warningShownRef.current = false;
schedulePrompt();
})
.catch(() => {
// Refresh failed — api-client will redirect on next 401
});
}
}, delayMs);
}

schedulePrompt();

return clearTimer;
}, [enabled, onRefresh, clearTimer]);
}
55 changes: 55 additions & 0 deletions frontend/lib/session-state-preserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Simple helper to preserve in-progress form and upload state across a
* session-expiry redirect.
*
* Usage:
* // Before the redirect (e.g. in a form component):
* preserveSessionState("upload-form", { file: fileData, annotations: [...] });
*
* // After login, restore:
* const saved = restoreSessionState<UploadState>("upload-form");
* if (saved) { /* repopulate form */ }
*
* State is stored in sessionStorage so it survives a redirect but is
* automatically cleared when the tab is closed.
*/

const SESSION_STATE_PREFIX = "smalda-session-state:";

/**
* Save in-progress state before a session-expiry redirect.
* Serializes to JSON and stores in sessionStorage.
*/
export function preserveSessionState<T = unknown>(
key: string,
state: T,
): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(
`${SESSION_STATE_PREFIX}${key}`,
JSON.stringify(state),
);
} catch {
// sessionStorage full or unavailable — best-effort only
}
}

/**
* Restore previously-saved state after login. Returns null if nothing was
* saved under the given key. Clears the stored state after reading so it
* isn't restored twice.
*/
export function restoreSessionState<T = unknown>(
key: string,
): T | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(`${SESSION_STATE_PREFIX}${key}`);
if (!raw) return null;
window.sessionStorage.removeItem(`${SESSION_STATE_PREFIX}${key}`);
return JSON.parse(raw) as T;
} catch {
return null;
}
}
Loading