Skip to content
Merged
46 changes: 46 additions & 0 deletions client/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test';

// These tests cover the unauthenticated flows; they run without a saved
// session (see the 'auth-specs' project in playwright.config.ts).
// Several of them perform real logins, which overwrite the user's single
// stored refresh token server-side — so they must not overlap.
test.describe.configure({ mode: 'serial' });

test.describe('Authentication Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
Expand Down Expand Up @@ -68,6 +72,48 @@ test.describe('Authentication Flow', () => {
await expect(page.getByRole('menuitem', { name: /logout/i })).toBeVisible();
});

test('should keep the session when logging back in and reloading', async ({
page,
}) => {
const login = async () => {
await page.goto('/login');
await page.getByLabel(/email/i).fill('test@example.com');
await page.getByLabel(/password/i).fill('testpassword');
await page.getByRole('button', { name: /login|sign in/i }).click();
await expect(page).toHaveURL(/.*\/app/);
};

await login();

// Log out via the UI, then log back in — all within the same SPA session.
await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /logout/i }).click();
await login();

// A reload must restore the session from the refresh cookie.
await page.reload();
await expect(page).toHaveURL(/.*\/app/, { timeout: 10_000 });
await expect(page.getByRole('button', { name: /user menu/i })).toBeVisible({
timeout: 10_000,
});
});

test('should not call authenticated logout when bootstrap has no session', async ({
page,
}) => {
const logoutCalls: string[] = [];
page.on('request', (req) => {
if (req.url().includes('/api/auth/logout')) {
logoutCalls.push(req.url());
}
});

await page.goto('/login');
await expect(page.getByLabel(/email/i)).toBeVisible();

expect(logoutCalls).toEqual([]);
});

test('should register a new account and redirect to login', async ({
page,
}) => {
Expand Down
47 changes: 28 additions & 19 deletions client/src/context/auth/auth-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import { useState, useEffect } from 'react';
import { useEffect, useState } from 'react';

import { api } from '@/lib/api';
import { api, onSessionExpired, refreshAccessToken } from '@/lib/api';
import { type User } from '@/types/api';
import { setAccessToken as storeToken, clearAccessToken } from '@/utils/token';

import { AuthContext } from './auth-context';

/**
* Session bootstrap: refreshes the token cookie on mount, exposes login/logout and mirrors the token into api defaults and module storage.
* Session bootstrap: restores the session from the refresh cookie on mount,
* exposes login/logout and mirrors the token into api defaults and module
* storage. A failed bootstrap simply means signed out — it never calls the
* authenticated logout endpoint.
*/
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);

const markSignedOut = () => {
setAccessToken(null);
setUser(null);
delete api.defaults.headers.common['Authorization'];
clearAccessToken();
};

const login = (token: string, user: User) => {
setAccessToken(token);
setUser(user);
Expand All @@ -22,33 +32,32 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
};

const logout = async () => {
await api.post('/auth/logout');
setAccessToken(null);
setUser(null);
localStorage.setItem('wasLoggedOut', 'true');
delete api.defaults.headers.common['Authorization'];
clearAccessToken();
// The server may already be unreachable or the session expired; local
// cleanup happens either way.
try {
await api.post('/auth/logout');
} catch {
// session already dead server-side
}
markSignedOut();
};

useEffect(() => {
const refresh = async () => {
if (localStorage.getItem('wasLoggedOut') === 'true') {
localStorage.removeItem('wasLoggedOut');
setLoading(false);
return;
}
const bootstrap = async () => {
try {
const res = await api.post('/auth/refresh');
const { accessToken, user } = res.data;
// Shares the single-flight with the response interceptor, so a
// bootstrap racing an in-flight refresh triggers only one request.
const { accessToken, user } = await refreshAccessToken();
login(accessToken, user);
} catch {
logout();
markSignedOut();
} finally {
setLoading(false);
}
};

refresh();
bootstrap();
return onSessionExpired(markSignedOut);
}, []);

if (loading) return null;
Expand Down
157 changes: 157 additions & 0 deletions client/src/lib/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { createServer, type Server } from 'node:http';

import axios from 'axios';
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';

import { clearAccessToken, setAccessToken } from '@/utils/token';

import { api, onSessionExpired } from '../api';

const PORT = 4599;
const BASE = `http://localhost:${PORT}`;

let server: Server;
let refreshCallCount = 0;
let refreshShouldFail = false;
const protectedAuthHeaders: string[] = [];

/**
* Minimal API stub: /protected requires the *new* token, /api/auth/refresh
* issues it once. Behaves like the real server for the paths under test.
*/
async function startStub(): Promise<void> {
server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
if (req.url === '/api/auth/refresh') {
refreshCallCount += 1;
res.setHeader('Content-Type', 'application/json');
if (refreshShouldFail) {
res.statusCode = 401;
res.end(JSON.stringify({ error: 'Unauthorized' }));
} else {
res.end(
JSON.stringify({ accessToken: 'new-token', user: { id: 'u1' } }),
);
}
return;
}
if (req.url === '/protected') {
protectedAuthHeaders.push(req.headers.authorization ?? '');
if (req.headers.authorization === 'Bearer new-token') {
res.end(JSON.stringify({ ok: true }));
} else {
res.statusCode = 401;
res.end(JSON.stringify({ error: 'Invalid or expired token' }));
}
return;
}
if (req.url === '/always-401') {
res.statusCode = 401;
res.end(JSON.stringify({ error: 'Invalid or expired token' }));
return;
}
res.statusCode = 404;
res.end();
});
});
await new Promise<void>((resolve) => server.listen(PORT, resolve));
}

beforeAll(startStub);
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));

beforeEach(() => {
vi.restoreAllMocks();
});

describe('api response interceptor', () => {
it('retries a request once after refreshing on 401', async () => {
refreshCallCount = 0;
protectedAuthHeaders.length = 0;
setAccessToken('old-token');

const res = await api.get(`${BASE}/protected`);

expect(res.data).toEqual({ ok: true });
expect(refreshCallCount).toBe(1);
// first attempt used stale token, replay used the refreshed one
expect(protectedAuthHeaders).toEqual([
'Bearer old-token',
'Bearer new-token',
]);
});

it('issues only one refresh for concurrent 401 responses', async () => {
refreshCallCount = 0;
protectedAuthHeaders.length = 0;
setAccessToken('old-token');

const [a, b, c] = await Promise.all([
api.get(`${BASE}/protected`),
api.get(`${BASE}/protected`),
api.get(`${BASE}/protected`),
]);

expect(a.data).toEqual({ ok: true });
expect(b.data).toEqual({ ok: true });
expect(c.data).toEqual({ ok: true });
expect(refreshCallCount).toBe(1);
});

it('clears the token and notifies listeners when refresh fails', async () => {
refreshShouldFail = true;
setAccessToken('expired-token');
const expired = vi.fn();
const unsubscribe = onSessionExpired(expired);

await expect(api.get(`${BASE}/always-401`)).rejects.toThrow();

expect(expired).toHaveBeenCalledTimes(1);
unsubscribe();
refreshShouldFail = false;
});

it('notifies sessionExpired listeners only once for concurrent refresh failures', async () => {
refreshShouldFail = true;
refreshCallCount = 0;
setAccessToken('expired-token');
const expired = vi.fn();
const unsubscribe = onSessionExpired(expired);

await Promise.allSettled([
api.get(`${BASE}/always-401`),
api.get(`${BASE}/always-401`),
api.get(`${BASE}/always-401`),
]);

expect(expired).toHaveBeenCalledTimes(1);
expect(refreshCallCount).toBe(1);
unsubscribe();
refreshShouldFail = false;
});

it('does not attempt a refresh when no access token is stored', async () => {
refreshCallCount = 0;
clearAccessToken();

await expect(api.get(`${BASE}/always-401`)).rejects.toThrow();
expect(refreshCallCount).toBe(0);
});

it('uses the shared instance against the configured base URL without manual base joining', async () => {
// sanity check that the exported api is an axios instance wired to env config
expect(
axios.isAxiosError(await api.get(`${BASE}/missing`).catch((e) => e)),
).toBe(true);
});
});
Loading
Loading