mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user