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 { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { refreshStoredAuthSession } from "@/lib/auth-session";
|
||||||
import { checkKillSwitch } from "@/lib/kill-switch";
|
import { checkKillSwitch } from "@/lib/kill-switch";
|
||||||
|
|
||||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
const { isAuthenticated, isLoading: authLoading, logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [killSwitchMsg, setKillSwitchMsg] = useState<string | null>(null);
|
const [killSwitchMsg, setKillSwitchMsg] = useState<string | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
|
let active = true;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
return;
|
return () => { active = false; };
|
||||||
}
|
}
|
||||||
|
|
||||||
checkKillSwitch()
|
async function verifySessionAndReadiness() {
|
||||||
.then(({ disabled, message }) => {
|
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) {
|
if (disabled) {
|
||||||
setKillSwitchMsg(message || "This application is currently unavailable.");
|
setKillSwitchMsg(message || "This application is currently unavailable.");
|
||||||
} else {
|
} else {
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}
|
}
|
||||||
})
|
} catch {
|
||||||
.catch(() => {
|
if (!active) return;
|
||||||
setReady(true);
|
setReady(true);
|
||||||
});
|
}
|
||||||
}, [isAuthenticated, authLoading, router]);
|
}
|
||||||
|
|
||||||
|
void verifySessionAndReadiness();
|
||||||
|
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [isAuthenticated, authLoading, logout, router]);
|
||||||
|
|
||||||
if (killSwitchMsg) {
|
if (killSwitchMsg) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
import { createApiClient } from "@bytelyst/api-client";
|
import { createApiClient } from "@bytelyst/api-client";
|
||||||
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
|
import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
|
||||||
|
import { clearAuthSession, isAccessTokenExpired } from "@/lib/auth-session";
|
||||||
|
|
||||||
export function getAccessToken(): string | null {
|
export function getAccessToken(): string | null {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return null;
|
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() {
|
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 { createAuthProvider } from "@bytelyst/react-auth";
|
||||||
import type { ProductUser } from "@/lib/types";
|
import type { ProductUser } from "@/lib/types";
|
||||||
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
|
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
|
||||||
|
import { restoreAuthSession } from "@/lib/auth-session";
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
user?: ProductUser;
|
user?: ProductUser;
|
||||||
@ -19,6 +20,8 @@ export const { AuthProvider, useAuth } = createAuthProvider<ProductUser>({
|
|||||||
changePasswordEndpoint: "/auth/change-password",
|
changePasswordEndpoint: "/auth/change-password",
|
||||||
deleteAccountEndpoint: "/auth/delete-account",
|
deleteAccountEndpoint: "/auth/delete-account",
|
||||||
refreshEndpoint: "/auth/refresh",
|
refreshEndpoint: "/auth/refresh",
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
onInit: restoreAuthSession,
|
||||||
mapLoginResponse: (data: unknown) => {
|
mapLoginResponse: (data: unknown) => {
|
||||||
const result = data as LoginResponse;
|
const result = data as LoginResponse;
|
||||||
return {
|
return {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user