feat(auth): migrate JWT storage from localStorage to httpOnly cookies

Eliminates XSS token theft risk by storing session JWTs in an httpOnly
cookie (trek_session) instead of localStorage, making them inaccessible
to JavaScript entirely.

- Add cookie-parser middleware and setAuthCookie/clearAuthCookie helpers
- Set trek_session cookie on login, register, demo-login, MFA verify, OIDC exchange
- Auth middleware reads cookie first, falls back to Authorization: Bearer (MCP unchanged)
- Add POST /api/auth/logout to clear the cookie server-side
- Remove all localStorage auth_token reads/writes from client
- Axios uses withCredentials; raw fetch calls use credentials: include
- WebSocket ws-token exchange uses credentials: include (no JWT param)
- authStore initialises isLoading: true so ProtectedRoute waits for /api/auth/me

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jubnl
2026-04-01 11:02:45 +02:00
parent 9292acb979
commit add0b17e04
14 changed files with 115 additions and 68 deletions

View File

@@ -22,6 +22,7 @@ import { writeAudit, getClientIp } from '../services/auditLog';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto';
import { startTripReminders } from '../scheduler';
import { createEphemeralToken } from '../services/ephemeralTokens';
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
authenticator.options = { window: 1 };
@@ -229,6 +230,7 @@ router.post('/demo-login', (_req: Request, res: Response) => {
if (!user) return res.status(500).json({ error: 'Demo user not found' });
const token = generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>;
setAuthCookie(res, token);
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
});
@@ -307,6 +309,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
}
writeAudit({ userId: Number(result.lastInsertRowid), action: 'user.register', ip: getClientIp(req), details: { username, email, role } });
setAuthCookie(res, token);
res.status(201).json({ token, user: { ...user, avatar_url: null } });
} catch (err: unknown) {
res.status(500).json({ error: 'Error creating user' });
@@ -350,6 +353,7 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
const userSafe = stripUserForClient(user) as Record<string, unknown>;
writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { email } });
setAuthCookie(res, token);
res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } });
});
@@ -367,6 +371,11 @@ router.get('/me', authenticate, (req: Request, res: Response) => {
res.json({ user: { ...base, avatar_url: avatarUrl(user) } });
});
router.post('/logout', (req: Request, res: Response) => {
clearAuthCookie(res);
res.json({ success: true });
});
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (isOidcOnlyMode()) {
@@ -810,6 +819,7 @@ router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
const sessionToken = generateToken(user);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, sessionToken);
res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } });
} catch {
return res.status(401).json({ error: 'Invalid or expired verification token' });

View File

@@ -6,6 +6,7 @@ import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { User } from '../types';
import { decrypt_api_key } from '../services/apiKeyCrypto';
import { setAuthCookie } from '../services/cookie';
interface OidcDiscoveryDoc {
authorization_endpoint: string;
@@ -289,6 +290,7 @@ router.get('/exchange', (req: Request, res: Response) => {
if (!entry) return res.status(400).json({ error: 'Invalid or expired code' });
authCodes.delete(code);
if (Date.now() - entry.created > AUTH_CODE_TTL) return res.status(400).json({ error: 'Code expired' });
setAuthCookie(res, entry.token);
res.json({ token: entry.token });
});