Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions frontend/src/__tests__/auth/client-bypass.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('<img src=x onerror=alert(1)>@example.com');
const xssEmail = '<img src=x onerror=alert(1)>@example.com';
seedPasswordlessClientUser(xssEmail);
const raw = localStorageMock.getItem('regengine_user');
expect(raw).toContain('<img src=x onerror=alert(1)>');
expect(raw).not.toBeNull();
expect(raw as string).not.toContain('<img src=x onerror=alert(1)>');
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([]);
});
Expand Down
73 changes: 60 additions & 13 deletions frontend/src/__tests__/hooks/useAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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',
Expand All @@ -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');
});
Expand Down Expand Up @@ -399,15 +407,15 @@ 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',
permissions: ['inflow.write', 'reports.read'],
};
const supabaseUser = {
id: 'supabase-user',
email: 'stored@example.com',
email: `${crypto.randomUUID()}@example.test`,
tenant_id: 'tenant-123',
role_name: 'member',
is_sysadmin: false,
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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';
});
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/__tests__/lib/client-session-profile.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)) {
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);
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved

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<string, unknown>;
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);
});
});
16 changes: 11 additions & 5 deletions frontend/src/lib/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */ }
}

Expand All @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/lib/client-session-profile.ts
Original file line number Diff line number Diff line change
@@ -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: '',
Comment thread
PetrefiedThunder marked this conversation as resolved.
Resolved
is_sysadmin: false,
status: 'active',
role_name: 'member',
permissions: [],
};

window.localStorage.setItem('regengine_user', JSON.stringify(user));
window.localStorage.setItem('regengine_user', serializeClientSessionUser(user));
}
Loading