diff --git a/.secrets.baseline b/.secrets.baseline index 6bdd5380a..269bd1e9a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2275,7 +2275,7 @@ "filename": "frontend/src/lib/auth-context.tsx", "hashed_secret": "77821f02d353c282dc3e4ea86a845a3fb8a9aa44", "is_verified": false, - "line_number": 63 + "line_number": 64 } ], "frontend/src/lib/gs1-parser.test.ts": [ @@ -3738,5 +3738,5 @@ } ] }, - "generated_at": "2026-10-25T07:05:37Z" + "generated_at": "2026-10-26T14:49:21Z" } diff --git a/frontend/src/__tests__/auth/client-bypass.test.tsx b/frontend/src/__tests__/auth/client-bypass.test.tsx index 341989d74..ab5f7331e 100644 --- a/frontend/src/__tests__/auth/client-bypass.test.tsx +++ b/frontend/src/__tests__/auth/client-bypass.test.tsx @@ -108,15 +108,18 @@ describe('client-side auth bypass attempts (adversarial, must hold)', () => { expect(result.current.user).toBeNull(); }); - it('XSS-shaped email text seeded client-side stays inert data, not markup', async () => { + it('XSS-shaped email text seeded client-side is never persisted', async () => { const { seedPasswordlessClientUser } = await import('@/lib/passwordless-auth'); - seedPasswordlessClientUser('@example.com'); + const xssEmail = '@example.com'; + seedPasswordlessClientUser(xssEmail); const raw = localStorageMock.getItem('regengine_user'); - expect(raw).toContain(''); + expect(raw).not.toBeNull(); + expect(raw as string).not.toContain(''); + expect(raw as string).not.toContain(xssEmail); const parsed = JSON.parse(raw as string) as { email: string; is_sysadmin: boolean; permissions: string[]; }; - expect(typeof parsed.email).toBe('string'); + expect(parsed.email).toBe(''); expect(parsed.is_sysadmin).toBe(false); expect(parsed.permissions).toEqual([]); }); diff --git a/frontend/src/__tests__/hooks/useAuth.test.tsx b/frontend/src/__tests__/hooks/useAuth.test.tsx index 52a8ee57f..f6ab0c6dc 100644 --- a/frontend/src/__tests__/hooks/useAuth.test.tsx +++ b/frontend/src/__tests__/hooks/useAuth.test.tsx @@ -66,6 +66,7 @@ describe('useAuth Hook', () => { beforeEach(() => { vi.clearAllMocks(); localStorageMock.clear(); + window.sessionStorage.clear(); (isSupabaseConfigured as any).mockReturnValue(false); (createSupabaseBrowserClient as any).mockImplementation(() => { throw new Error('Supabase not configured'); @@ -102,10 +103,10 @@ describe('useAuth Hook', () => { expect(result.current.accessToken).toBeNull(); }); - it('hydrates from localStorage on mount', async () => { + it('hydrates authenticated email in memory without retaining it in storage', async () => { const mockUser = { id: '123', - email: 'test@example.com', + email: `${crypto.randomUUID()}@example.test`, name: 'Test User', is_sysadmin: false, tenant_id: 'tenant-123', @@ -145,6 +146,10 @@ describe('useAuth Hook', () => { }); expect(result.current.user).toEqual(mockUser); + expect(apiClient.setUser).toHaveBeenLastCalledWith(mockUser); + expect(JSON.parse(localStorageMock.getItem('regengine_user')!)).toEqual({ ...mockUser, email: '' }); + expect(localStorageMock.getItem('regengine_user')).not.toContain(mockUser.email); + expect(window.sessionStorage.getItem('regengine_user')).toBeNull(); // Access token is now a placeholder since actual token is in HTTP-only cookie expect(result.current.accessToken).toBe('cookie-managed'); }); @@ -184,10 +189,10 @@ describe('useAuth Hook', () => { expect(result.current.accessToken).toBe('cookie-managed'); }); - it('persists user to localStorage on login', async () => { + it('keeps login email in memory and out of browser storage', async () => { const mockUser = { id: '123', - email: 'test@example.com', + email: `${crypto.randomUUID()}@example.test`, name: 'Test User', is_sysadmin: false, tenant_id: 'tenant-123', @@ -204,8 +209,11 @@ describe('useAuth Hook', () => { await result.current.login('test-token', mockUser, 'tenant-123'); }); - // User profile is still stored in localStorage (non-sensitive) - expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(mockUser)); + expect(result.current.user).toEqual(mockUser); + expect(apiClient.setUser).toHaveBeenLastCalledWith(mockUser); + expect(JSON.parse(localStorageMock.getItem('regengine_user')!)).toEqual({ ...mockUser, email: '' }); + expect(localStorageMock.getItem('regengine_user')).not.toContain(mockUser.email); + expect(window.sessionStorage.getItem('regengine_user')).toBeNull(); // Tenant ID is still stored in localStorage (non-sensitive) expect(localStorageMock.getItem('regengine_tenant_id')).toBe('tenant-123'); }); @@ -399,7 +407,7 @@ describe('useAuth Hook', () => { it('hydrates Supabase fallback sessions from /api/session instead of local browser auth state', async () => { const storedUser = { id: 'stored-user', - email: 'stored@example.com', + email: `${crypto.randomUUID()}@example.test`, is_sysadmin: false, status: 'active', role_name: 'owner', @@ -407,7 +415,7 @@ describe('useAuth Hook', () => { }; const supabaseUser = { id: 'supabase-user', - email: 'stored@example.com', + email: `${crypto.randomUUID()}@example.test`, tenant_id: 'tenant-123', role_name: 'member', is_sysadmin: false, @@ -442,7 +450,7 @@ describe('useAuth Hook', () => { }); expect(result.current.user?.permissions).toEqual(['inflow.write', 'reports.read']); - expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(supabaseUser)); + expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...supabaseUser, email: '' })); expect(createSupabaseBrowserClient).not.toHaveBeenCalled(); }); @@ -808,7 +816,7 @@ describe('useAuth Hook', () => { describe('Stale session cleanup (hydration)', () => { const staleUser = { id: 'stale-user', - email: 'stale@example.com', + email: `${crypto.randomUUID()}@example.test`, is_sysadmin: false, status: 'active', role_name: 'member', @@ -882,7 +890,10 @@ describe('useAuth Hook', () => { }); expect(result.current.user?.id).toBe('stale-user'); - expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(staleUser)); + expect(result.current.user?.email).toBe(staleUser.email); + expect(apiClient.setUser).toHaveBeenLastCalledWith(expect.objectContaining({ email: staleUser.email })); + expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...staleUser, email: '' })); + expect(window.sessionStorage.getItem('regengine_user')).toBeNull(); }); it('keeps the hydrated user when the session check returns a non-2xx response', async () => { @@ -903,7 +914,41 @@ describe('useAuth Hook', () => { }); expect(result.current.user?.id).toBe('stale-user'); - expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(staleUser)); + expect(result.current.user?.email).toBe(staleUser.email); + expect(apiClient.setUser).toHaveBeenLastCalledWith(expect.objectContaining({ email: staleUser.email })); + expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...staleUser, email: '' })); + expect(window.sessionStorage.getItem('regengine_user')).toBeNull(); + }); + + it('does not replace a completed login with the redacted cache after migration awaits', async () => { + const loginUser = { + id: crypto.randomUUID(), + email: `${crypto.randomUUID()}@example.test`, + is_sysadmin: false, + status: 'active', + }; + localStorageMock.setItem('regengine_access_token', crypto.randomUUID()); + const defaultFetch = mockFetch.getMockImplementation()!; + let resolveMigration: ((value: { ok: boolean }) => void) | undefined; + mockFetch.mockImplementation((url: string, options?: RequestInit) => { + if (options?.method === 'POST' && !resolveMigration) { + return new Promise((resolve) => { resolveMigration = resolve; }); + } + return defaultFetch(url, options); + }); + const { result } = renderHook(() => useAuth(), { wrapper }); + await waitFor(() => expect(resolveMigration).toBeDefined()); + + await act(async () => { + await result.current.login(crypto.randomUUID(), loginUser); + resolveMigration!({ ok: true }); + }); + await waitFor(() => expect(result.current.isHydrated).toBe(true)); + + expect(result.current.user).toEqual(loginUser); + expect(apiClient.setUser).toHaveBeenLastCalledWith(loginUser); + expect(JSON.parse(localStorageMock.getItem('regengine_user')!)).toEqual({ ...loginUser, email: '' }); + expect(window.sessionStorage.getItem('regengine_user')).toBeNull(); }); it('does not wipe a login that completed while the hydrate session check was in flight', async () => { @@ -968,7 +1013,9 @@ describe('useAuth Hook', () => { }); expect(result.current.user?.id).toBe('fresh-user'); - expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(mockUser)); + expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...mockUser, email: '' })); + expect(result.current.user?.email).toBe(mockUser.email); + expect(apiClient.setUser).toHaveBeenLastCalledWith(expect.objectContaining({ email: mockUser.email })); document.cookie = 're_csrf=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; }); diff --git a/frontend/src/__tests__/lib/client-session-profile.test.ts b/frontend/src/__tests__/lib/client-session-profile.test.ts new file mode 100644 index 000000000..bd7736760 --- /dev/null +++ b/frontend/src/__tests__/lib/client-session-profile.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { seedClientSessionUser, serializeClientSessionUser } from '@/lib/client-session-profile'; + +function collectEmailValues(value: unknown, acc: unknown[] = []): unknown[] { + if (Array.isArray(value)) { + for (const item of value) collectEmailValues(item, acc); + } else if (value !== null && typeof value === 'object') { + for (const [key, entry] of Object.entries(value as Record)) { + if (key.toLowerCase() === 'email') acc.push(entry); + collectEmailValues(entry, acc); + } + } + return acc; +} + +describe('seedClientSessionUser', () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + it('stores a non-sensitive placeholder profile without the email', () => { + const email = `${crypto.randomUUID()}@example.test`; + + seedClientSessionUser(email); + + const stored = window.localStorage.getItem('regengine_user'); + expect(stored).not.toBeNull(); + expect(stored as string).not.toContain(email); + expect(stored as string).not.toContain(email.split('@')[0]); + expect(window.sessionStorage.getItem('regengine_user')).toBeNull(); + + const parsed = JSON.parse(stored as string) as Record; + expect(parsed).toMatchObject({ + id: 'email-session', + email: '', + is_sysadmin: false, + status: 'active', + role_name: 'member', + permissions: [], + }); + + const emailValues = collectEmailValues(parsed); + expect(emailValues.length).toBeGreaterThan(0); + for (const value of emailValues) { + expect(value).toBe(''); + } + }); +}); + +describe('serializeClientSessionUser', () => { + it('omits email without mutating the authenticated profile or its permissions', () => { + const user = { + id: crypto.randomUUID(), + email: `${crypto.randomUUID()}@example.test`, + is_sysadmin: false, + status: 'active', + role_name: 'member', + permissions: ['reports.read'], + }; + const original = structuredClone(user); + + const stored = serializeClientSessionUser(user); + + expect(JSON.parse(stored)).toEqual({ ...original, email: '' }); + expect(stored).not.toContain(original.email); + expect(stored).not.toContain(original.email.split('@')[0]); + expect(user).toEqual(original); + }); +}); diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index f9d671e0c..f4d7b064d 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -5,6 +5,7 @@ import type { User, SessionAuthMethod } from '@/types/api'; import { apiClient } from './api-client'; import { fetchWithCsrf } from './fetch-with-csrf'; import { sanitizePermissions } from './permissions'; +import { serializeClientSessionUser } from './client-session-profile'; interface AuthContextType { user: User | null; @@ -42,7 +43,7 @@ type LoginOptions = { * ONLY in HTTP-only cookies via /api/session. They are never readable * by client-side JavaScript, eliminating XSS credential theft. * - * Non-sensitive data (tenant_id, onboarded, demo_mode, user profile) + * Non-sensitive data (tenant_id, onboarded, demo_mode, profile without email) * remains in localStorage for fast hydration. * * The deprecated apiKey field in the context is still set to a placeholder @@ -285,8 +286,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (storedUser) { try { const parsedUser = normalizeUserProfile(JSON.parse(storedUser) as User); - setUserState(parsedUser); - apiClient.setUser(parsedUser); + localStorage.setItem(STORAGE_KEYS.USER, serializeClientSessionUser(parsedUser)); + if (!loginCompletedRef.current) { + setUserState(parsedUser); + apiClient.setUser(parsedUser); + } } catch { /* ignore corrupt data */ } } @@ -307,7 +311,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const normalizedSessionUser = normalizeUserProfile(session.user); setUserState(normalizedSessionUser); apiClient.setUser(normalizedSessionUser); - localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(normalizedSessionUser)); + localStorage.setItem(STORAGE_KEYS.USER, serializeClientSessionUser(normalizedSessionUser)); } setAccessTokenState(COOKIE_MANAGED_PLACEHOLDER); apiClient.setAccessToken(COOKIE_MANAGED_PLACEHOLDER); @@ -411,7 +415,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (loginTenantId) apiClient.setCurrentTenant(loginTenantId); if (typeof window !== 'undefined') { - localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(normalizedLoginUser)); + localStorage.setItem(STORAGE_KEYS.USER, serializeClientSessionUser(normalizedLoginUser)); if (loginTenantId) localStorage.setItem(STORAGE_KEYS.TENANT_ID, loginTenantId); } @@ -443,6 +447,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } loginCompletedRef.current = true; + // Hydration may have read the redacted cache while the cookie write awaited. + apiClient.setUser(normalizedLoginUser); // React state updates - triggers re-renders and the navigation useEffect. // Cookie should be stored in the browser at this point. diff --git a/frontend/src/lib/client-session-profile.ts b/frontend/src/lib/client-session-profile.ts index 0bb6c4bc3..197928700 100644 --- a/frontend/src/lib/client-session-profile.ts +++ b/frontend/src/lib/client-session-profile.ts @@ -1,16 +1,26 @@ import type { User } from '@/types/api'; +// Keep the authenticated profile intact in memory; cache no email in web storage. +export function serializeClientSessionUser(user: User | null): string { + return JSON.stringify(user ? { ...user, email: '' } : null); +} + +// Seeds a non-sensitive placeholder profile only: the email argument is +// intentionally never persisted to web storage nor held in module state +// (CodeQL js/clear-text-storage-of-sensitive-data). The real session fetch +// provides the authenticated user in memory. export function seedClientSessionUser(email: string): void { + void email; if (typeof window === 'undefined') return; const user: User = { id: 'email-session', - email, + email: '', is_sysadmin: false, status: 'active', role_name: 'member', permissions: [], }; - window.localStorage.setItem('regengine_user', JSON.stringify(user)); + window.localStorage.setItem('regengine_user', serializeClientSessionUser(user)); }