fix(web): harden auth session refresh flow

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:40:55 -07:00
parent b1c358def3
commit 6418ab2836
6 changed files with 352 additions and 9 deletions

View File

@ -0,0 +1,73 @@
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { checkKillSwitchMock, logoutMock, refreshStoredAuthSessionMock, replaceMock, useAuthMock } = vi.hoisted(() => ({
checkKillSwitchMock: vi.fn(async () => ({ disabled: false, message: "" })),
logoutMock: vi.fn(),
refreshStoredAuthSessionMock: vi.fn(async () => true),
replaceMock: vi.fn(),
useAuthMock: vi.fn(() => ({ isAuthenticated: true, isLoading: false, logout: logoutMock })),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: replaceMock }),
}));
vi.mock("@/lib/auth", () => ({
useAuth: useAuthMock,
}));
vi.mock("@/lib/auth-session", () => ({
refreshStoredAuthSession: refreshStoredAuthSessionMock,
}));
vi.mock("@/lib/kill-switch", () => ({
checkKillSwitch: checkKillSwitchMock,
}));
import { AuthGuard } from "./AuthGuard";
describe("AuthGuard", () => {
beforeEach(() => {
vi.clearAllMocks();
useAuthMock.mockReturnValue({ isAuthenticated: true, isLoading: false, logout: logoutMock });
refreshStoredAuthSessionMock.mockResolvedValue(true);
checkKillSwitchMock.mockResolvedValue({ disabled: false, message: "" });
});
it("redirects unauthenticated users to login", async () => {
useAuthMock.mockReturnValue({ isAuthenticated: false, isLoading: false, logout: logoutMock });
render(<AuthGuard><div>Private app</div></AuthGuard>);
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/login"));
expect(screen.queryByText("Private app")).not.toBeInTheDocument();
});
it("refreshes an expired stored session before showing protected content", async () => {
render(<AuthGuard><div>Private app</div></AuthGuard>);
await waitFor(() => expect(refreshStoredAuthSessionMock).toHaveBeenCalledOnce());
expect(await screen.findByText("Private app")).toBeInTheDocument();
expect(logoutMock).not.toHaveBeenCalled();
});
it("logs out and redirects when session refresh fails", async () => {
refreshStoredAuthSessionMock.mockResolvedValue(false);
render(<AuthGuard><div>Private app</div></AuthGuard>);
await waitFor(() => expect(logoutMock).toHaveBeenCalledOnce());
expect(replaceMock).toHaveBeenCalledWith("/login");
expect(screen.queryByText("Private app")).not.toBeInTheDocument();
});
it("keeps protected content hidden when the kill switch is active", async () => {
checkKillSwitchMock.mockResolvedValue({ disabled: true, message: "Maintenance window" });
render(<AuthGuard><div>Private app</div></AuthGuard>);
expect(await screen.findByText("Maintenance window")).toBeInTheDocument();
expect(screen.queryByText("Private app")).not.toBeInTheDocument();
});
});

View File

@ -3,34 +3,52 @@
import { useEffect, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { refreshStoredAuthSession } from "@/lib/auth-session";
import { checkKillSwitch } from "@/lib/kill-switch";
export function AuthGuard({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { isAuthenticated, isLoading: authLoading, logout } = useAuth();
const router = useRouter();
const [killSwitchMsg, setKillSwitchMsg] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
if (authLoading) return;
let active = true;
if (!isAuthenticated) {
router.replace("/login");
return;
return () => { active = false; };
}
checkKillSwitch()
.then(({ disabled, message }) => {
async function verifySessionAndReadiness() {
const refreshed = await refreshStoredAuthSession();
if (!active) return;
if (!refreshed) {
logout();
router.replace("/login");
return;
}
try {
const { disabled, message } = await checkKillSwitch();
if (!active) return;
if (disabled) {
setKillSwitchMsg(message || "This application is currently unavailable.");
} else {
setReady(true);
}
})
.catch(() => {
} catch {
if (!active) return;
setReady(true);
});
}, [isAuthenticated, authLoading, router]);
}
}
void verifySessionAndReadiness();
return () => { active = false; };
}, [isAuthenticated, authLoading, logout, router]);
if (killSwitchMsg) {
return (

View File

@ -1,12 +1,18 @@
import { createApiClient } from "@bytelyst/api-client";
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
import { clearAuthSession, isAccessTokenExpired } from "@/lib/auth-session";
export function getAccessToken(): string | null {
if (typeof window === "undefined") {
return null;
}
return localStorage.getItem(`${PRODUCT_ID}_access_token`);
const token = localStorage.getItem(`${PRODUCT_ID}_access_token`);
if (isAccessTokenExpired(token)) {
clearAuthSession();
return null;
}
return token;
}
export function createNotesApiClient() {

View File

@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
ACCESS_TOKEN_KEY,
AUTH_USER_KEY,
REFRESH_TOKEN_KEY,
clearAuthSession,
isAccessTokenExpired,
refreshStoredAuthSession,
restoreAuthSession,
} from "@/lib/auth-session";
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
const storage = new Map<string, string>();
const localStorageMock = {
getItem: vi.fn((key: string) => storage.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn((key: string) => {
storage.delete(key);
}),
};
function tokenWithExp(exp: number): string {
const payload = btoa(JSON.stringify({ exp })).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return `header.${payload}.sig`;
}
function jsonResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
};
}
describe("auth-session", () => {
beforeEach(() => {
storage.clear();
vi.clearAllMocks();
Object.defineProperty(window, "localStorage", {
configurable: true,
value: localStorageMock,
});
vi.stubGlobal("localStorage", localStorageMock);
vi.stubGlobal("fetch", vi.fn());
});
it("detects expired JWT access tokens with skew", () => {
expect(isAccessTokenExpired(tokenWithExp(100), 101_000, 0)).toBe(true);
expect(isAccessTokenExpired(tokenWithExp(200), 101_000, 0)).toBe(false);
});
it("restores a valid product-scoped session from localStorage", () => {
const accessToken = tokenWithExp(Math.floor(Date.now() / 1000) + 3600);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify({ email: "a@b.com", name: "A", role: "editor", workspaceId: "ws-1" }));
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, "refresh-token");
expect(restoreAuthSession()).toMatchObject({
user: { email: "a@b.com" },
accessToken,
refreshToken: "refresh-token",
});
});
it("clears an expired session when no refresh token exists", () => {
localStorage.setItem(AUTH_USER_KEY, JSON.stringify({ email: "a@b.com" }));
localStorage.setItem(ACCESS_TOKEN_KEY, tokenWithExp(1));
expect(restoreAuthSession()).toBeNull();
expect(localStorageMock.removeItem).toHaveBeenCalledWith(AUTH_USER_KEY);
expect(localStorageMock.removeItem).toHaveBeenCalledWith(ACCESS_TOKEN_KEY);
expect(localStorageMock.removeItem).toHaveBeenCalledWith(REFRESH_TOKEN_KEY);
});
it("refreshes an expired access token through platform-service", async () => {
const fetchMock = vi.fn(async () => jsonResponse({ accessToken: "new-access", refreshToken: "new-refresh" }));
vi.stubGlobal("fetch", fetchMock);
localStorage.setItem(ACCESS_TOKEN_KEY, tokenWithExp(1));
localStorage.setItem(REFRESH_TOKEN_KEY, "old-refresh");
await expect(refreshStoredAuthSession()).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledWith(`${PLATFORM_SERVICE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-product-id": PRODUCT_ID },
body: JSON.stringify({ refreshToken: "old-refresh" }),
});
expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBe("new-access");
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBe("new-refresh");
});
it("clears the session when refresh fails", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ error: "expired" }, 401)));
localStorage.setItem(AUTH_USER_KEY, JSON.stringify({ email: "a@b.com" }));
localStorage.setItem(ACCESS_TOKEN_KEY, tokenWithExp(1));
localStorage.setItem(REFRESH_TOKEN_KEY, "bad-refresh");
await expect(refreshStoredAuthSession()).resolves.toBe(false);
expect(localStorage.getItem(AUTH_USER_KEY)).toBeNull();
expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull();
});
it("clears all product-scoped auth keys on logout cleanup", () => {
localStorage.setItem(AUTH_USER_KEY, "u");
localStorage.setItem(ACCESS_TOKEN_KEY, "a");
localStorage.setItem(REFRESH_TOKEN_KEY, "r");
clearAuthSession();
expect(localStorage.getItem(AUTH_USER_KEY)).toBeNull();
expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull();
});
});

127
web/src/lib/auth-session.ts Normal file
View File

@ -0,0 +1,127 @@
import type { ProductUser } from "@/lib/types";
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
export const AUTH_USER_KEY = `${PRODUCT_ID}_auth_user`;
export const ACCESS_TOKEN_KEY = `${PRODUCT_ID}_access_token`;
export const REFRESH_TOKEN_KEY = `${PRODUCT_ID}_refresh_token`;
interface JwtPayload {
exp?: number;
}
interface RefreshResponse {
accessToken?: string;
refreshToken?: string;
}
function getStorage(): Storage | null {
if (typeof window === "undefined") return null;
return window.localStorage;
}
function decodeBase64Url(value: string): string {
const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), "=");
return atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
}
export function readJwtPayload(token: string): JwtPayload | null {
const [, payload] = token.split(".");
if (!payload) return null;
try {
return JSON.parse(decodeBase64Url(payload)) as JwtPayload;
} catch {
return null;
}
}
export function isAccessTokenExpired(token: string | null, nowMs = Date.now(), skewSeconds = 30): boolean {
if (!token) return true;
const payload = readJwtPayload(token);
if (!payload?.exp) return false;
return payload.exp * 1000 <= nowMs + skewSeconds * 1000;
}
export function clearAuthSession(): void {
const storage = getStorage();
if (!storage) return;
storage.removeItem(AUTH_USER_KEY);
storage.removeItem(ACCESS_TOKEN_KEY);
storage.removeItem(REFRESH_TOKEN_KEY);
}
export function getStoredAccessToken(): string | null {
return getStorage()?.getItem(ACCESS_TOKEN_KEY) ?? null;
}
export function getStoredRefreshToken(): string | null {
return getStorage()?.getItem(REFRESH_TOKEN_KEY) ?? null;
}
export function restoreAuthSession():
| { user: ProductUser; accessToken: string; refreshToken: string }
| null {
const storage = getStorage();
if (!storage) return null;
const rawUser = storage.getItem(AUTH_USER_KEY);
const accessToken = storage.getItem(ACCESS_TOKEN_KEY);
const refreshToken = storage.getItem(REFRESH_TOKEN_KEY);
if (!rawUser || !accessToken) return null;
if (isAccessTokenExpired(accessToken) && !refreshToken) {
clearAuthSession();
return null;
}
try {
return {
user: JSON.parse(rawUser) as ProductUser,
accessToken,
refreshToken: refreshToken ?? "",
};
} catch {
clearAuthSession();
return null;
}
}
export async function refreshStoredAuthSession(): Promise<boolean> {
const storage = getStorage();
if (!storage) return false;
const accessToken = storage.getItem(ACCESS_TOKEN_KEY);
if (!isAccessTokenExpired(accessToken)) return true;
const refreshToken = storage.getItem(REFRESH_TOKEN_KEY);
if (!refreshToken) {
clearAuthSession();
return false;
}
try {
const response = await fetch(`${PLATFORM_SERVICE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-product-id": PRODUCT_ID },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
clearAuthSession();
return false;
}
const data = (await response.json()) as RefreshResponse;
if (!data.accessToken || !data.refreshToken) {
clearAuthSession();
return false;
}
storage.setItem(ACCESS_TOKEN_KEY, data.accessToken);
storage.setItem(REFRESH_TOKEN_KEY, data.refreshToken);
return true;
} catch {
clearAuthSession();
return false;
}
}

View File

@ -3,6 +3,7 @@
import { createAuthProvider } from "@bytelyst/react-auth";
import type { ProductUser } from "@/lib/types";
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
import { restoreAuthSession } from "@/lib/auth-session";
interface LoginResponse {
user?: ProductUser;
@ -19,6 +20,8 @@ export const { AuthProvider, useAuth } = createAuthProvider<ProductUser>({
changePasswordEndpoint: "/auth/change-password",
deleteAccountEndpoint: "/auth/delete-account",
refreshEndpoint: "/auth/refresh",
productId: PRODUCT_ID,
onInit: restoreAuthSession,
mapLoginResponse: (data: unknown) => {
const result = data as LoginResponse;
return {