refactor(server): move the JWT verify into nest/auth, delete four dead middlewares (#1828)

middleware/auth.ts exported seven things. Two of them — extractToken and
verifyJwtAndLoadUser — are the canonical session check every auth surface goes
through. The other five (authenticate, requireCookieAuth, optionalAuth,
adminOnly, demoUploadBlock) are Express middlewares with no production caller
left; the guards in nest/auth replaced them and only comments still mention
them.

The two live functions move to nest/auth/jwt-verify.ts and the file goes, along
with middleware/validate.ts (nothing but its own test imported it) and
middleware/idempotency.ts (its only caller was `authenticate`, and
IdempotencyInterceptor has replaced it). AuthRequest and OptionalAuthRequest go
with them — src/typings/express.d.ts is what types req.user now.

Eight importers repoint: the three guards, auth/files/plugins-proxy services,
platform.routes and the global MFA policy.

Two deliberate departures from the plan for this package.

JWT_SECRET does not move to app-config. It is not an env value: it is generated
into data/.jwt_secret, and the admin panel rotates it at runtime through
updateJwtSecret. `export let` is precisely what makes a rotation take effect
in-process — a registerAs token would freeze the boot value and silently break
rotation.

The verify also keeps reading the db singleton rather than DatabaseService. That
change is not a one-line swap: it gives the three guards a constructor
dependency, which means registering them as providers everywhere they are used.
Measured: 38 directories use those guards and 21 of them do not import
AuthModule, on top of 34 e2e tests that each build a container around a single
domain module. That is its own PR, not a rider on one that deletes dead files.

tests/unit/middleware/auth.test.ts covered real behaviour, so it is not simply
deleted. Its extractToken cases and the five verify cases it drove through
`authenticate` come back as tests/unit/nest/auth/jwt-verify.test.ts, now hitting
verifyJwtAndLoadUser directly. Four cases are new: the password_version gate —
the whole reason this function exists — had no direct coverage at all. Its three
adminOnly cases are dropped because AdminGuard, their successor, is covered by
three e2e suites.
This commit is contained in:
Maurice
2026-08-07 18:01:24 +02:00
committed by GitHub
parent a634b2a26b
commit 9814480a96
25 changed files with 215 additions and 725 deletions
-120
View File
@@ -1,120 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { readEnv } from '../app-config';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { applyIdempotency } from './idempotency';
import { isDemoEmail } from '../nest/common/demo';
export 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'];
return (authHeader && authHeader.split(' ')[1]) || null;
}
/**
* Verify a JWT and load its user, enforcing the password_version gate.
*
* Exported so every auth surface in the codebase (MCP bearer tokens,
* file download query tokens, the photo-serving route) goes through the
* same check. A password reset bumps `users.password_version`, which
* invalidates every JWT that embedded the prior value — but only if
* every verify path actually compares the claim. Previously several
* paths called `jwt.verify` directly and skipped the DB lookup, so a
* stolen token kept working after the victim reset.
*/
export function verifyJwtAndLoadUser(token: string): User | null {
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
// Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
// secret but are not full session tokens — only their dedicated endpoint
// may accept them, so reject any token carrying a purpose claim here.
if (decoded.purpose) return null;
const row = db.prepare(
'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
).get(decoded.id) as (User & { password_version?: number }) | undefined;
if (!row) return null;
// Session invalidation: any token whose embedded password_version
// predates the user's current one is rejected. Tokens issued before
// the `pv` claim existed (decoded.pv === undefined) are treated as
// version 0 so legacy sessions keep working until the user resets.
const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0;
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
if (tokenPv !== currentPv) return null;
// Don't leak password_version beyond the middleware.
const { password_version: _pv, ...user } = row;
return user as User;
} catch {
return null;
}
}
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
const token = extractToken(req);
if (!token) {
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
return;
}
const user = verifyJwtAndLoadUser(token);
if (!user) {
res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
applyIdempotency(req, res, next, user.id);
};
/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
* Used on state-mutating OAuth endpoints (consent POST, client CRUD, session revoke)
* to prevent Bearer JWT tokens obtained by other means from managing OAuth clients. */
const requireCookieAuth = (req: Request, res: Response, next: NextFunction): void => {
const cookieToken = (req as any).cookies?.trek_session;
if (!cookieToken) {
res.status(401).json({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
return;
}
const user = verifyJwtAndLoadUser(cookieToken);
if (!user) {
res.status(401).json({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' });
return;
}
(req as AuthRequest).user = user;
next();
};
const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
const token = extractToken(req);
if (!token) {
(req as OptionalAuthRequest).user = null;
return next();
}
(req as OptionalAuthRequest).user = verifyJwtAndLoadUser(token) || null;
next();
};
const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthRequest;
if (!authReq.user || authReq.user.role !== 'admin') {
res.status(403).json({ error: 'Admin access required' });
return;
}
next();
};
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthRequest;
if (readEnv().demo.enabled && isDemoEmail(authReq.user?.email)) {
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
return;
}
next();
};
export { authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock };
-80
View File
@@ -1,80 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { db } from '../db/database';
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
// Reject pathological client-supplied keys outright instead of hashing
// everything — 128 chars is plenty for any realistic UUID / ULID / nonce.
const MAX_KEY_LENGTH = 128;
// Responses larger than this are not worth caching — a backup-restore
// endpoint could otherwise store a megabyte-sized JSON body per request
// key and, with mass key creation, blow up idempotency_keys.
const MAX_CACHED_BODY_BYTES = 256 * 1024;
interface IdempotencyRow {
status_code: number;
response_body: string;
}
/**
* Called from within `authenticate` after req.user is set.
*
* For mutating requests carrying X-Idempotency-Key:
* - If (key, userId, method, path) already stored: replays the cached response.
* - Otherwise: wraps res.json to capture and store a successful response.
*
* The lookup is scoped by method + path as well as user so the same key
* replayed against a different endpoint doesn't return the cached body
* of an unrelated request. Key length is capped and the cached body is
* skipped when it exceeds `MAX_CACHED_BODY_BYTES`.
*
* Storing happens in idempotency_keys (24h TTL, cleaned by scheduler).
*/
export function applyIdempotency(req: Request, res: Response, next: NextFunction, userId: number): void {
if (!MUTATING_METHODS.has(req.method)) {
next();
return;
}
const key = req.headers['x-idempotency-key'] as string | undefined;
if (!key) {
next();
return;
}
if (key.length > MAX_KEY_LENGTH) {
res.status(400).json({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' });
return;
}
// Return cached response only if the same key was seen for the same
// user AND the same method+path — avoids a POST's cached body leaking
// into an unrelated PATCH that reused the idempotency-key string.
const existing = db.prepare(
'SELECT status_code, response_body FROM idempotency_keys WHERE key = ? AND user_id = ? AND method = ? AND path = ?'
).get(key, userId, req.method, req.path) as IdempotencyRow | undefined;
if (existing) {
res.status(existing.status_code).json(JSON.parse(existing.response_body));
return;
}
// Wrap res.json to capture the response on first successful execution
const originalJson = res.json.bind(res);
res.json = function (body: unknown): Response {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const serialized = JSON.stringify(body);
if (serialized.length <= MAX_CACHED_BODY_BYTES) {
db.prepare(
`INSERT OR IGNORE INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(key, userId, req.method, req.path, res.statusCode, serialized, Math.floor(Date.now() / 1000));
}
} catch {
// Non-fatal: if storage fails, the request still succeeds
}
}
return originalJson(body);
};
next();
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import { readEnv } from '../app-config';
import { db } from '../db/database';
import { extractToken, verifyJwtAndLoadUser } from './auth';
import { extractToken, verifyJwtAndLoadUser } from '../nest/auth/jwt-verify';
import { DEMO_EMAILS } from '../nest/common/demo';
/** Paths that never require MFA (public or pre-auth). */
-26
View File
@@ -1,26 +0,0 @@
import { Request, Response, NextFunction } from 'express';
function maxLength(field: string, max: number) {
return (req: Request, res: Response, next: NextFunction): void => {
if (req.body[field] && typeof req.body[field] === 'string' && req.body[field].length > max) {
res.status(400).json({ error: `${field} must be ${max} characters or less` });
return;
}
next();
};
}
function validateStringLengths(maxLengths: Record<string, number>) {
return (req: Request, res: Response, next: NextFunction): void => {
for (const [field, max] of Object.entries(maxLengths)) {
const value = req.body[field];
if (value && typeof value === 'string' && value.length > max) {
res.status(400).json({ error: `${field} must be ${max} characters or less` });
return;
}
}
next();
};
}
export { maxLength, validateStringLengths };