diff --git a/web/src/components/AuthGuard.test.tsx b/web/src/components/AuthGuard.test.tsx new file mode 100644 index 0000000..79a2fdb --- /dev/null +++ b/web/src/components/AuthGuard.test.tsx @@ -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(
Private app
); + + 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(
Private app
); + + 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(
Private app
); + + 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(
Private app
); + + expect(await screen.findByText("Maintenance window")).toBeInTheDocument(); + expect(screen.queryByText("Private app")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/AuthGuard.tsx b/web/src/components/AuthGuard.tsx index a1aef2b..bcb01d8 100644 --- a/web/src/components/AuthGuard.tsx +++ b/web/src/components/AuthGuard.tsx @@ -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(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 ( diff --git a/web/src/lib/api-helpers.ts b/web/src/lib/api-helpers.ts index 55b0f10..ab56beb 100644 --- a/web/src/lib/api-helpers.ts +++ b/web/src/lib/api-helpers.ts @@ -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() { diff --git a/web/src/lib/auth-session.test.ts b/web/src/lib/auth-session.test.ts new file mode 100644 index 0000000..8796a23 --- /dev/null +++ b/web/src/lib/auth-session.test.ts @@ -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(); +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(); + }); +}); diff --git a/web/src/lib/auth-session.ts b/web/src/lib/auth-session.ts new file mode 100644 index 0000000..6c6b592 --- /dev/null +++ b/web/src/lib/auth-session.ts @@ -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 { + 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; + } +} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index cd30e8d..5ce07b6 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -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({ 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 {