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 {