From 356f8b47fa35b037807a5946c79a8954b94c8eb2 Mon Sep 17 00:00:00 2001 From: cybermaxi7 Date: Wed, 19 Aug 2026 18:04:20 +0100 Subject: [PATCH] feat(web): add frontend authentication with route protection Adds `apps/web/lib/auth/`: a login flow, session storage, and a route guard for the dashboard. Token storage defaults to memory, not localStorage -------------------------------------------------- A bearer token in localStorage is readable by any script that manages to run on the page, so one XSS becomes full account takeover of a security console. The default `MemoryTokenStorage` keeps the token in the JS heap, where it dies with the tab and is never reachable through document-level access. The cost is honest and stated: a refresh signs the user out. Persisting safely across reloads needs the refresh token in an httpOnly cookie the frontend cannot read, which requires backend support that does not exist yet. `LocalStorageTokenStorage` is provided for teams that accept the trade, but it is opt-in rather than the default. Its every access is guarded, because localStorage throws in private browsing and when the quota is exceeded, and a failure to persist must not take down the login flow. Three states, not two --------------------- `AuthStatus` is `unknown | authenticated | unauthenticated`. On first render the app has not yet inspected storage, and collapsing that into "unauthenticated" flashes the login screen at users who are in fact signed in. `ProtectedRoute` renders a loading state for `unknown`. It also separates being signed out from lacking permission. The first should send you to sign in; the second should tell you that signing in again will not help. `requiredRoles` mirrors the backend `Role` enum in `src/modules/rbac/roles.enum.ts`, so the UI gates on the values the API issues. Other decisions --------------- - `expiresIn` is converted to an absolute `expiresAt` at the API boundary, so the rest of the app compares timestamps rather than recomputing a deadline from a duration whose origin it no longer knows. - Logout clears local state first and never throws. A user on a flaky connection asked to sign out, and must not be left holding a session because the network call failed. - Expired sessions found in storage are cleared on read rather than left to fail on first use. - `useAuth` throws outside a provider, since that is a wiring bug rather than a state to handle. - The API client follows the existing `lib/api/profile.ts` conventions: same base URL resolution, same typed-error shape, same response parsing. Tests: 33 across three suites covering memory and localStorage backends, corrupt JSON, quota and security exceptions, expiry handling, session restore, login success and rejection, logout including the failing-network path, the provider-less error, and every ProtectedRoute branch including the unresolved state. Full dashboard suite: 74 passing across 8 suites, no regressions. Lint and prettier clean. Note: the client targets `/auth/login`, `/auth/logout` and `/auth/me`. The backend has an RBAC guard but no auth controller yet, so those endpoints still need implementing server-side; this establishes the contract the frontend expects. --- apps/web/lib/auth/AuthContext.tsx | 130 +++++++ apps/web/lib/auth/LoginForm.tsx | 80 +++++ apps/web/lib/auth/ProtectedRoute.tsx | 52 +++ .../lib/auth/ProtectedRoute.unknown.spec.tsx | 35 ++ apps/web/lib/auth/auth-api.ts | 81 +++++ apps/web/lib/auth/auth.spec.tsx | 334 ++++++++++++++++++ apps/web/lib/auth/index.ts | 11 + apps/web/lib/auth/token-storage.spec.ts | 151 ++++++++ apps/web/lib/auth/token-storage.ts | 89 +++++ apps/web/lib/auth/types.ts | 64 ++++ 10 files changed, 1027 insertions(+) create mode 100644 apps/web/lib/auth/AuthContext.tsx create mode 100644 apps/web/lib/auth/LoginForm.tsx create mode 100644 apps/web/lib/auth/ProtectedRoute.tsx create mode 100644 apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx create mode 100644 apps/web/lib/auth/auth-api.ts create mode 100644 apps/web/lib/auth/auth.spec.tsx create mode 100644 apps/web/lib/auth/index.ts create mode 100644 apps/web/lib/auth/token-storage.spec.ts create mode 100644 apps/web/lib/auth/token-storage.ts create mode 100644 apps/web/lib/auth/types.ts diff --git a/apps/web/lib/auth/AuthContext.tsx b/apps/web/lib/auth/AuthContext.tsx new file mode 100644 index 0000000..12afa67 --- /dev/null +++ b/apps/web/lib/auth/AuthContext.tsx @@ -0,0 +1,130 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import * as authApi from './auth-api'; +import { MemoryTokenStorage, TokenStorage } from './token-storage'; +import { + AuthApiError, + AuthSession, + AuthStatus, + AuthUser, + LoginCredentials, + isSessionValid, +} from './types'; + +export interface AuthContextValue { + status: AuthStatus; + user: AuthUser | null; + /** Current access token, or null. Exposed for API clients that need it. */ + token: string | null; + /** Last login failure, cleared on the next attempt. */ + error: string | null; + isSubmitting: boolean; + login: (credentials: LoginCredentials) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export interface AuthProviderProps { + children: React.ReactNode; + /** Injectable so tests, and apps that opt into persistence, can swap it. */ + storage?: TokenStorage; +} + +export const AuthProvider: React.FC = ({ children, storage }) => { + // Held in a ref so swapping storage never re-runs the restore effect, and so + // the default instance is stable across renders. + const storageRef = useRef(storage ?? new MemoryTokenStorage()); + + const [session, setSession] = useState(null); + const [status, setStatus] = useState('unknown'); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Restore once on mount. Until this runs, status stays 'unknown' so guards + // render a loading state rather than briefly showing the login screen to + // someone who is already signed in. + useEffect(() => { + const restored = storageRef.current.read(); + if (isSessionValid(restored)) { + setSession(restored); + setStatus('authenticated'); + } else { + // An expired entry is cleared rather than left to fail on first use. + storageRef.current.clear(); + setStatus('unauthenticated'); + } + }, []); + + const login = useCallback(async (credentials: LoginCredentials): Promise => { + setIsSubmitting(true); + setError(null); + + try { + const newSession = await authApi.login(credentials); + storageRef.current.write(newSession); + setSession(newSession); + setStatus('authenticated'); + return true; + } catch (caught) { + const message = + caught instanceof AuthApiError && caught.isCredentialFailure + ? 'Incorrect email or password.' + : caught instanceof Error + ? caught.message + : 'Unable to sign in. Please try again.'; + + setError(message); + setStatus('unauthenticated'); + return false; + } finally { + setIsSubmitting(false); + } + }, []); + + const logout = useCallback(async (): Promise => { + const token = session?.accessToken; + + // Local state is cleared first. If the network call is slow or fails, the + // user is still signed out of this tab, which is what they asked for. + storageRef.current.clear(); + setSession(null); + setStatus('unauthenticated'); + setError(null); + + if (token) { + await authApi.logout(token); + } + }, [session]); + + const value = useMemo( + () => ({ + status, + user: session?.user ?? null, + token: session?.accessToken ?? null, + error, + isSubmitting, + login, + logout, + }), + [status, session, error, isSubmitting, login, logout], + ); + + return {children}; +}; + +/** Access the auth state. Throws outside a provider, which is a wiring bug. */ +export const useAuth = (): AuthContextValue => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/apps/web/lib/auth/LoginForm.tsx b/apps/web/lib/auth/LoginForm.tsx new file mode 100644 index 0000000..b7b54bc --- /dev/null +++ b/apps/web/lib/auth/LoginForm.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; +import { useAuth } from './AuthContext'; + +export interface LoginFormProps { + /** Called after a successful sign-in. */ + onSuccess?: () => void; + heading?: string; +} + +/** + * Credential sign-in form. + * + * The submit button stays enabled while fields are empty so that pressing it + * surfaces validation messages, rather than leaving the user with a dead + * control and no explanation. It disables only while a request is in flight. + */ +export const LoginForm: React.FC = ({ + onSuccess, + heading = 'Sign in to Sentinel', +}) => { + const { login, error, isSubmitting } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [validationError, setValidationError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!email.trim() || !password) { + setValidationError('Enter both your email and password.'); + return; + } + setValidationError(null); + + const ok = await login({ email: email.trim(), password }); + if (ok) onSuccess?.(); + }; + + const message = validationError ?? error; + + return ( +
+

{heading}

+ + + + + + {message && ( +

+ {message} +

+ )} + + +
+ ); +}; + +export default LoginForm; diff --git a/apps/web/lib/auth/ProtectedRoute.tsx b/apps/web/lib/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..83e1026 --- /dev/null +++ b/apps/web/lib/auth/ProtectedRoute.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useAuth } from './AuthContext'; +import { AuthRole, hasAnyRole } from './types'; + +export interface ProtectedRouteProps { + children: React.ReactNode; + /** When non-empty, the user must hold at least one of these roles. */ + requiredRoles?: AuthRole[]; + /** Shown to unauthenticated users. Typically the login screen. */ + fallback?: React.ReactNode; + /** Shown to authenticated users who lack the required role. */ + forbiddenFallback?: React.ReactNode; + /** Shown while the session is still being resolved. */ + loading?: React.ReactNode; +} + +/** + * Gates its children on authentication, and optionally on role. + * + * Three states, not two. While `status` is `unknown` the app has not yet + * inspected storage, and rendering the fallback then would flash the login + * screen at users who are in fact signed in — so that case renders `loading`. + * + * Being signed out and lacking permission are also kept distinct: the first + * should send you to sign in, the second should tell you that signing in again + * will not help. + */ +export const ProtectedRoute: React.FC = ({ + children, + requiredRoles = [], + fallback = null, + forbiddenFallback = null, + loading = null, +}) => { + const { status, user } = useAuth(); + + if (status === 'unknown') { + return <>{loading}; + } + + if (status !== 'authenticated' || !user) { + return <>{fallback}; + } + + if (!hasAnyRole(user, requiredRoles)) { + return <>{forbiddenFallback}; + } + + return <>{children}; +}; + +export default ProtectedRoute; diff --git a/apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx b/apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx new file mode 100644 index 0000000..900cbc5 --- /dev/null +++ b/apps/web/lib/auth/ProtectedRoute.unknown.spec.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +// The 'unknown' window closes as soon as the provider's restore effect runs, so +// it cannot be observed through AuthProvider in a test. Stubbing useAuth pins +// the contract directly: an unresolved session must render `loading`, never the +// signed-out fallback, or users who are signed in see the login screen flash. +jest.mock('./AuthContext', () => ({ + useAuth: () => ({ + status: 'unknown', + user: null, + token: null, + error: null, + isSubmitting: false, + login: jest.fn(), + logout: jest.fn(), + }), +})); + +import { ProtectedRoute } from './ProtectedRoute'; + +describe('ProtectedRoute while the session is unresolved', () => { + it('renders the loading state, not the signed-out fallback', () => { + render( + Checking session

} fallback={

Sign in

}> +

Incident data

+
, + ); + + expect(screen.getByText('Checking session')).toBeInTheDocument(); + expect(screen.queryByText('Sign in')).not.toBeInTheDocument(); + expect(screen.queryByText('Incident data')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/lib/auth/auth-api.ts b/apps/web/lib/auth/auth-api.ts new file mode 100644 index 0000000..e600c18 --- /dev/null +++ b/apps/web/lib/auth/auth-api.ts @@ -0,0 +1,81 @@ +import { AuthApiError, AuthSession, AuthUser, LoginCredentials } from './types'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000/api'; + +/** Shape the API returns on a successful login. */ +interface LoginResponse { + accessToken: string; + /** Lifetime in seconds, as issued by the API. */ + expiresIn: number; + user: AuthUser; +} + +async function parseResponse(response: Response): Promise { + const body = (await response.json().catch(() => ({}))) as { + message?: string | string[]; + }; + + if (!response.ok) { + const message = Array.isArray(body.message) + ? body.message.join(', ') + : (body.message ?? `Request failed with status ${response.status}`); + throw new AuthApiError(message, response.status); + } + + return body as T; +} + +const authHeaders = (token: string): HeadersInit => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, +}); + +/** + * Exchange credentials for a session. + * + * `expiresIn` is converted to an absolute `expiresAt` at the boundary, so the + * rest of the app compares timestamps rather than recomputing a deadline from a + * duration whose origin it no longer knows. + */ +export async function login(credentials: LoginCredentials): Promise { + const response = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + + const body = await parseResponse(response); + return { + accessToken: body.accessToken, + expiresAt: Date.now() + body.expiresIn * 1000, + user: body.user, + }; +} + +/** + * Invalidate the session server-side. + * + * Deliberately never throws. Logout must clear local state even when the + * network call fails, otherwise a user on a flaky connection is stuck holding a + * session they asked to end. + */ +export async function logout(token: string): Promise { + try { + await fetch(`${API_BASE}/auth/logout`, { + method: 'POST', + headers: authHeaders(token), + }); + } catch { + // Swallowed on purpose — see above. + } +} + +/** Re-read the current user, used to validate a restored session. */ +export async function fetchCurrentUser(token: string): Promise { + const response = await fetch(`${API_BASE}/auth/me`, { + method: 'GET', + headers: authHeaders(token), + }); + + return parseResponse(response); +} diff --git a/apps/web/lib/auth/auth.spec.tsx b/apps/web/lib/auth/auth.spec.tsx new file mode 100644 index 0000000..69e7f8f --- /dev/null +++ b/apps/web/lib/auth/auth.spec.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { AuthProvider, useAuth } from './AuthContext'; +import { ProtectedRoute } from './ProtectedRoute'; +import { LoginForm } from './LoginForm'; +import { MemoryTokenStorage } from './token-storage'; +import { AuthSession, AuthRole } from './types'; + +const validSession = (roles: AuthRole[] = ['USER']): AuthSession => ({ + accessToken: 'token-123', + expiresAt: Date.now() + 60_000, + user: { id: 'u1', email: 'analyst@sentinel.test', name: 'Analyst', roles }, +}); + +/** Minimal fetch stub; each test declares the response it cares about. */ +const mockFetch = (impl: (url: string) => Partial & { json: () => Promise }) => { + (globalThis as unknown as { fetch: jest.Mock }).fetch = jest.fn((url: string) => + Promise.resolve(impl(String(url)) as Response), + ); +}; + +const okLogin = (roles: AuthRole[] = ['USER']) => + mockFetch(() => ({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + accessToken: 'token-123', + expiresIn: 3600, + user: { id: 'u1', email: 'analyst@sentinel.test', roles }, + }), + })); + +/** Surfaces context state for assertions. */ +const AuthProbe: React.FC = () => { + const { status, user, token } = useAuth(); + return ( +
+ {status} + {user?.email ?? 'none'} + {token ?? 'none'} +
+ ); +}; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('AuthProvider', () => { + it('resolves to unauthenticated when storage is empty', async () => { + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated')); + }); + + it('restores a valid session from storage', async () => { + const storage = new MemoryTokenStorage(); + storage.write(validSession()); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); + expect(screen.getByTestId('user')).toHaveTextContent('analyst@sentinel.test'); + }); + + it('ignores and clears an expired stored session', async () => { + const storage = new MemoryTokenStorage(); + storage.write({ ...validSession(), expiresAt: Date.now() - 1_000 }); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated')); + expect(storage.read()).toBeNull(); + }); + + it('signs in and persists the session', async () => { + okLogin(); + const storage = new MemoryTokenStorage(); + + render( + + + + , + ); + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'analyst@sentinel.test' }, + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'correct-horse' }, + }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); + expect(storage.read()?.accessToken).toBe('token-123'); + }); + + it('converts expiresIn into an absolute expiry', async () => { + okLogin(); + const storage = new MemoryTokenStorage(); + const before = Date.now(); + + render( + + + , + ); + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.c' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(storage.read()).not.toBeNull()); + const expiresAt = storage.read()!.expiresAt; + expect(expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000); + }); + + it('reports rejected credentials in plain language', async () => { + mockFetch(() => ({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: 'Unauthorized' }), + })); + + render( + + + + , + ); + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.c' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/incorrect email or password/i), + ); + expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated'); + }); + + it('signs out and clears storage', async () => { + okLogin(); + const storage = new MemoryTokenStorage(); + storage.write(validSession()); + + const Controls: React.FC = () => { + const { logout } = useAuth(); + return ( + + ); + }; + + render( + + + + , + ); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /sign out/i })); + }); + + expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated'); + expect(storage.read()).toBeNull(); + }); + + it('signs out locally even when the logout request fails', async () => { + mockFetch(() => { + throw new Error('network down'); + }); + + const storage = new MemoryTokenStorage(); + storage.write(validSession()); + + const Controls: React.FC = () => { + const { logout } = useAuth(); + return ( + + ); + }; + + render( + + + + , + ); + + await waitFor(() => expect(screen.getByTestId('status')).toHaveTextContent('authenticated')); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /sign out/i })); + }); + + expect(screen.getByTestId('status')).toHaveTextContent('unauthenticated'); + expect(storage.read()).toBeNull(); + }); + + it('throws when useAuth is used outside a provider', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow(/must be used within an AuthProvider/i); + spy.mockRestore(); + }); +}); + +describe('ProtectedRoute', () => { + const Secret = () =>

Incident data

; + + it('shows the fallback when signed out', async () => { + render( + + Sign in

}> + +
+
, + ); + + await waitFor(() => expect(screen.getByText('Sign in')).toBeInTheDocument()); + expect(screen.queryByText('Incident data')).not.toBeInTheDocument(); + }); + + it('renders children when signed in', async () => { + const storage = new MemoryTokenStorage(); + storage.write(validSession()); + + render( + + Sign in

}> + +
+
, + ); + + await waitFor(() => expect(screen.getByText('Incident data')).toBeInTheDocument()); + }); + + it('distinguishes lacking permission from being signed out', async () => { + const storage = new MemoryTokenStorage(); + storage.write(validSession(['USER'])); + + render( + + Sign in

} + forbiddenFallback={

Administrator access required

} + > + +
+
, + ); + + await waitFor(() => + expect(screen.getByText('Administrator access required')).toBeInTheDocument(), + ); + // Being told to sign in again would be misleading — they already are. + expect(screen.queryByText('Sign in')).not.toBeInTheDocument(); + }); + + it('admits a user holding one of the required roles', async () => { + const storage = new MemoryTokenStorage(); + storage.write(validSession(['MODERATOR'])); + + render( + + + + + , + ); + + await waitFor(() => expect(screen.getByText('Incident data')).toBeInTheDocument()); + }); +}); + +describe('LoginForm', () => { + it('asks for both fields before calling the API', async () => { + const fetchSpy = jest.fn(); + (globalThis as unknown as { fetch: jest.Mock }).fetch = fetchSpy; + + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/enter both your email and password/i), + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('calls onSuccess after signing in', async () => { + okLogin(); + const onSuccess = jest.fn(); + + render( + + + , + ); + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@b.c' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'pw' } }); + fireEvent.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/apps/web/lib/auth/index.ts b/apps/web/lib/auth/index.ts new file mode 100644 index 0000000..6941a5f --- /dev/null +++ b/apps/web/lib/auth/index.ts @@ -0,0 +1,11 @@ +export { AuthProvider, useAuth } from './AuthContext'; +export type { AuthContextValue, AuthProviderProps } from './AuthContext'; +export { ProtectedRoute } from './ProtectedRoute'; +export type { ProtectedRouteProps } from './ProtectedRoute'; +export { LoginForm } from './LoginForm'; +export type { LoginFormProps } from './LoginForm'; +export { MemoryTokenStorage, LocalStorageTokenStorage, SESSION_STORAGE_KEY } from './token-storage'; +export type { TokenStorage } from './token-storage'; +export { AuthApiError, hasAnyRole, isSessionValid } from './types'; +export type { AuthRole, AuthSession, AuthStatus, AuthUser, LoginCredentials } from './types'; +export * as authApi from './auth-api'; diff --git a/apps/web/lib/auth/token-storage.spec.ts b/apps/web/lib/auth/token-storage.spec.ts new file mode 100644 index 0000000..53c3f55 --- /dev/null +++ b/apps/web/lib/auth/token-storage.spec.ts @@ -0,0 +1,151 @@ +import { LocalStorageTokenStorage, MemoryTokenStorage, SESSION_STORAGE_KEY } from './token-storage'; +import { AuthSession, hasAnyRole, isSessionValid } from './types'; + +const session = (overrides: Partial = {}): AuthSession => ({ + accessToken: 'token-123', + expiresAt: Date.now() + 60_000, + user: { id: 'u1', email: 'analyst@sentinel.test', roles: ['USER'] }, + ...overrides, +}); + +describe('MemoryTokenStorage', () => { + it('round-trips a session', () => { + const storage = new MemoryTokenStorage(); + const value = session(); + + storage.write(value); + expect(storage.read()).toEqual(value); + }); + + it('returns null before anything is written', () => { + expect(new MemoryTokenStorage().read()).toBeNull(); + }); + + it('clears the session', () => { + const storage = new MemoryTokenStorage(); + storage.write(session()); + storage.clear(); + expect(storage.read()).toBeNull(); + }); + + it('does not leak the session to localStorage', () => { + const storage = new MemoryTokenStorage(); + storage.write(session()); + expect(globalThis.localStorage.getItem(SESSION_STORAGE_KEY)).toBeNull(); + }); +}); + +describe('LocalStorageTokenStorage', () => { + beforeEach(() => { + globalThis.localStorage.clear(); + jest.restoreAllMocks(); + }); + + it('persists a session across instances', () => { + const value = session(); + new LocalStorageTokenStorage().write(value); + + expect(new LocalStorageTokenStorage().read()).toEqual(value); + }); + + it('treats an expired entry as absent and removes it', () => { + const expired = session({ expiresAt: Date.now() - 1_000 }); + globalThis.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(expired)); + + const storage = new LocalStorageTokenStorage(); + expect(storage.read()).toBeNull(); + expect(globalThis.localStorage.getItem(SESSION_STORAGE_KEY)).toBeNull(); + }); + + it('survives corrupt JSON', () => { + globalThis.localStorage.setItem(SESSION_STORAGE_KEY, '{ not json'); + + expect(() => new LocalStorageTokenStorage().read()).not.toThrow(); + expect(new LocalStorageTokenStorage().read()).toBeNull(); + }); + + it('treats an entry without a token as absent', () => { + globalThis.localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ expiresAt: Date.now() + 60_000 }), + ); + expect(new LocalStorageTokenStorage().read()).toBeNull(); + }); + + // jsdom's localStorage is not spy-able, so these swap the whole object. + const withLocalStorage = (stub: Partial, run: () => void) => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + Object.defineProperty(globalThis, 'localStorage', { + value: stub, + configurable: true, + writable: true, + }); + try { + run(); + } finally { + if (original) Object.defineProperty(globalThis, 'localStorage', original); + } + }; + + it('does not throw when the quota is exceeded', () => { + withLocalStorage( + { + getItem: () => null, + setItem: () => { + throw new Error('QuotaExceededError'); + }, + removeItem: () => undefined, + }, + () => { + expect(() => new LocalStorageTokenStorage().write(session())).not.toThrow(); + }, + ); + }); + + it('does not throw when reading is blocked', () => { + withLocalStorage( + { + getItem: () => { + throw new Error('SecurityError'); + }, + setItem: () => undefined, + removeItem: () => undefined, + }, + () => { + expect(new LocalStorageTokenStorage().read()).toBeNull(); + }, + ); + }); +}); + +describe('session helpers', () => { + it('rejects a null session', () => { + expect(isSessionValid(null)).toBe(false); + }); + + it('rejects an expired session', () => { + expect(isSessionValid(session({ expiresAt: Date.now() - 1 }))).toBe(false); + }); + + it('accepts a live session', () => { + expect(isSessionValid(session())).toBe(true); + }); + + it('treats an empty role requirement as satisfied', () => { + expect(hasAnyRole(null, [])).toBe(true); + }); + + it('requires a user when roles are demanded', () => { + expect(hasAnyRole(null, ['ADMIN'])).toBe(false); + }); + + it('matches when the user holds one of several roles', () => { + const user = { id: 'u1', email: 'a@b.c', roles: ['MODERATOR' as const] }; + expect(hasAnyRole(user, ['ADMIN', 'MODERATOR'])).toBe(true); + }); + + it('rejects when the user holds none of them', () => { + const user = { id: 'u1', email: 'a@b.c', roles: ['USER' as const] }; + expect(hasAnyRole(user, ['ADMIN'])).toBe(false); + }); +}); diff --git a/apps/web/lib/auth/token-storage.ts b/apps/web/lib/auth/token-storage.ts new file mode 100644 index 0000000..1682d18 --- /dev/null +++ b/apps/web/lib/auth/token-storage.ts @@ -0,0 +1,89 @@ +import { AuthSession, isSessionValid } from './types'; + +/** + * Where the session lives between reads. + * + * Two implementations ship. The default is in-memory, and that is a deliberate + * choice for this product: a bearer token in `localStorage` is readable by any + * script that manages to run on the page, so a single XSS becomes full account + * takeover of a security console. Keeping it in the JS heap means the token + * dies with the tab and is never exposed to `document`-level access. + * + * The cost is that a page refresh logs the user out. Persisting across reloads + * safely needs the refresh token in an httpOnly cookie the frontend cannot + * read, which requires backend support that does not exist yet. Until then + * `LocalStorageTokenStorage` is available for teams that accept the trade, and + * it is opt-in rather than the default. + */ +export interface TokenStorage { + read(): AuthSession | null; + write(session: AuthSession): void; + clear(): void; +} + +/** Default. The token never leaves the JS heap. */ +export class MemoryTokenStorage implements TokenStorage { + private session: AuthSession | null = null; + + read(): AuthSession | null { + return this.session; + } + + write(session: AuthSession): void { + this.session = session; + } + + clear(): void { + this.session = null; + } +} + +export const SESSION_STORAGE_KEY = 'sentinel.auth.session'; + +/** + * Opt-in persistence across reloads. + * + * Every access is guarded: `localStorage` throws in private browsing modes and + * when the quota is exceeded, and a failure to persist a session must not take + * down the login flow. + */ +export class LocalStorageTokenStorage implements TokenStorage { + constructor(private readonly key: string = SESSION_STORAGE_KEY) {} + + read(): AuthSession | null { + try { + const raw = globalThis.localStorage?.getItem(this.key); + if (!raw) return null; + + const parsed = JSON.parse(raw) as AuthSession; + // A malformed or expired entry is treated as no session at all, and is + // removed so it cannot be re-read on every mount. + if (!parsed?.accessToken || !isSessionValid(parsed)) { + this.clear(); + return null; + } + return parsed; + } catch { + // Corrupt JSON, or storage unavailable. + this.clear(); + return null; + } + } + + write(session: AuthSession): void { + try { + globalThis.localStorage?.setItem(this.key, JSON.stringify(session)); + } catch { + // Quota exceeded or storage disabled — the in-memory session in + // AuthProvider remains authoritative for this tab. + } + } + + clear(): void { + try { + globalThis.localStorage?.removeItem(this.key); + } catch { + // Nothing to do; the caller is discarding the session regardless. + } + } +} diff --git a/apps/web/lib/auth/types.ts b/apps/web/lib/auth/types.ts new file mode 100644 index 0000000..60cee42 --- /dev/null +++ b/apps/web/lib/auth/types.ts @@ -0,0 +1,64 @@ +/** + * Frontend authentication types. + * + * `AuthRole` mirrors the backend `Role` enum in `src/modules/rbac/roles.enum.ts` + * so the values the UI gates on are the values the API actually issues. + */ +export type AuthRole = 'ADMIN' | 'MODERATOR' | 'USER'; + +export interface AuthUser { + id: string; + email: string; + name?: string; + roles: AuthRole[]; +} + +/** A logged-in session. `expiresAt` is epoch milliseconds. */ +export interface AuthSession { + accessToken: string; + expiresAt: number; + user: AuthUser; +} + +export interface LoginCredentials { + email: string; + password: string; +} + +/** + * Resolution state of the session. + * + * `unknown` matters: on first render the app has not yet inspected storage, and + * treating that as "unauthenticated" would flash the login screen at users who + * are in fact signed in. + */ +export type AuthStatus = 'unknown' | 'authenticated' | 'unauthenticated'; + +/** Mirrors the shape of `ProfileApiError` used elsewhere in `lib/api`. */ +export class AuthApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'AuthApiError'; + } + + /** Credentials were rejected, as opposed to the request failing. */ + get isCredentialFailure(): boolean { + return this.status === 401 || this.status === 403; + } +} + +/** Session is absent or past its expiry. */ +export const isSessionValid = ( + session: AuthSession | null, + now: number = Date.now(), +): session is AuthSession => session !== null && session.expiresAt > now; + +/** Whether a user holds at least one of the required roles. */ +export const hasAnyRole = (user: AuthUser | null, required: AuthRole[]): boolean => { + if (required.length === 0) return true; + if (!user) return false; + return required.some(role => user.roles.includes(role)); +};