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
+2
View File
@@ -3,6 +3,7 @@ import express, { Request, Response, NextFunction } from 'express';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import path from 'path';
import fs from 'fs';
@@ -86,6 +87,7 @@ if (shouldForceHttps) {
}
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(enforceGlobalMfaPolicy);
+10 -4
View File
@@ -4,9 +4,16 @@ import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
function extractToken(req: Request): string | null {
// Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
const cookieToken = (req as any).cookies?.trek_session;
if (cookieToken) return cookieToken;
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
return (authHeader && authHeader.split(' ')[1]) || null;
}
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
const token = extractToken(req);
if (!token) {
res.status(401).json({ error: 'Access token required' });
@@ -30,8 +37,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
};
const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
const token = extractToken(req);
if (!token) {
(req as OptionalAuthRequest).user = null;
+10
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' });
+2
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 });
});
+22
View File
@@ -0,0 +1,22 @@
import { Response } from 'express';
const COOKIE_NAME = 'trek_session';
function cookieOptions(clear = false) {
const secure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true';
return {
httpOnly: true,
secure,
sameSite: 'strict' as const,
path: '/',
...(clear ? {} : { maxAge: 24 * 60 * 60 * 1000 }), // 24h — matches JWT expiry
};
}
export function setAuthCookie(res: Response, token: string): void {
res.cookie(COOKIE_NAME, token, cookieOptions());
}
export function clearAuthCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, cookieOptions(true));
}