fix(web): harden auth session refresh flow
This commit is contained in:
parent
b1c358def3
commit
6418ab2836
73
web/src/components/AuthGuard.test.tsx
Normal file
73
web/src/components/AuthGuard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 (
|
||||
|
||||
@ -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() {
|
||||
|
||||
116
web/src/lib/auth-session.test.ts
Normal file
116
web/src/lib/auth-session.test.ts
Normal 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
127
web/src/lib/auth-session.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user