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