refactor(backend): extract authMiddleware and introduce createApp factory (phase 2) (#732)

Phase 2 of the index.ts refactor. Pulls the auth middleware and session
cookie issuers into their own module, and introduces the app.ts factory
that owns the first nine steps of the canonical middleware pipeline.

New modules:
- middleware/auth.ts: authMiddleware, issueSessionCookie,
  issueMfaPendingCookie, clearMfaPendingCookie
- app.ts: createApp() factory installing trust proxy, helmet, cors,
  compression, cookieParser, rate limiters, conditionalJsonParser, and
  nodeContextMiddleware. A header comment documents all 16 canonical
  middleware steps and where each currently lives.

Changes:
- middleware/authGate.ts: createAuthGate factory removed; authGate now
  imports authMiddleware directly (the factory existed only to avoid a
  circular import while authMiddleware lived in index.ts).
- services/DatabaseService.ts: added API_TOKEN_SCOPE_TO_ROLE map so the
  auth middleware no longer inlines a stringly-typed record.
- index.ts drops ~260 lines; auth routes, authGate, auditLog,
  apiTokenScope, remaining routes, static serving, and the error handler
  continue to be registered there until their respective phases.
- vitest.config.ts bumps testTimeout to 30s and hookTimeout to 45s so
  fork-pool workers have enough headroom to ts-node-transform the
  growing module graph under CPU contention (64 workers each import the
  full Express stack in beforeAll).

Code review fixes: use getErrorMessage() util in the auth catch block
instead of inline cast; promote the scope-to-role map to a typed
module-level constant.
This commit is contained in:
Anso
2026-04-23 19:02:13 -04:00
committed by GitHub
parent 856a260a11
commit ca5a930c68
6 changed files with 368 additions and 297 deletions
+200
View File
@@ -0,0 +1,200 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import {
DatabaseService,
API_TOKEN_SCOPE_TO_ROLE,
type UserRole,
type ApiTokenScope,
} from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
import {
isLicenseTier,
isLicenseVariant,
normalizeTier,
normalizeVariant,
PROXY_TIER_HEADER,
PROXY_VARIANT_HEADER,
} from '../services/LicenseService';
import { isDebugEnabled } from '../utils/debug';
import {
COOKIE_NAME,
MFA_PENDING_COOKIE_NAME,
MFA_PENDING_SCOPE,
MFA_PENDING_TTL_MS,
} from '../helpers/constants';
import { getCookieOptions } from '../helpers/cookies';
/**
* Authenticate a request via cookie session or Bearer token.
*
* Handles five scopes: user-session (cookie or bearer), api_token,
* mfa_pending, node_proxy, pilot_tunnel. Bearer token is preferred when both
* are present so node-to-node proxy calls aren't shadowed by a stale
* cross-instance cookie.
*/
export const authMiddleware: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const cookieToken = req.cookies[COOKIE_NAME];
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null;
const token = bearerToken || cookieToken;
if (!token) {
res.status(401).json({ error: 'Authentication required' });
return;
}
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean };
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
// API token path: scope-based programmatic access
if (decoded.scope === 'api_token') {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
if (!apiToken || apiToken.revoked_at) {
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: not found or revoked');
res.status(401).json({ error: 'API token not found or revoked' });
return;
}
if (apiToken.expires_at && apiToken.expires_at < Date.now()) {
if (isDebugEnabled()) console.log('[Auth:diag] API token rejected: expired');
res.status(401).json({ error: 'API token has expired' });
return;
}
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
req.user = {
username: creator?.username || `api-token:${apiToken.name}`,
role: API_TOKEN_SCOPE_TO_ROLE[apiToken.scope] ?? 'viewer',
userId: apiToken.user_id,
};
req.apiTokenScope = apiToken.scope as ApiTokenScope;
if (isDebugEnabled()) console.log('[Auth:diag] API token authenticated:', { scope: apiToken.scope, user: creator?.username, tokenName: apiToken.name });
next();
return;
}
// Partial-auth session: a password/SSO credential has verified, but the
// TOTP second factor is still required. Such a token can only be used to
// complete the MFA challenge or to abort the flow by logging out. Every
// other route must reject it so no privileged action is reachable before
// the second factor clears.
if (decoded.scope === MFA_PENDING_SCOPE) {
const allowedPath = req.path === '/api/auth/login/mfa' || req.path === '/api/auth/logout';
if (!allowedPath) {
res.status(403).json({ error: 'Two-factor authentication required', code: 'MFA_PENDING' });
return;
}
req.mfaPendingUserId = typeof decoded.user_id === 'number' ? decoded.user_id : undefined;
req.mfaPendingSso = decoded.sso === true;
next();
return;
}
// Node proxy tokens: Sencho-to-Sencho communication, not user sessions.
// Handle before user resolution since proxy tokens have no username.
// pilot_tunnel scope is the equivalent credential for pilot-agent-mode
// nodes; it arrives on requests the primary forwarded through a tunnel
// after the primary itself re-signed/trusted them. Same tier-header trust
// rules apply.
if (decoded.scope === 'node_proxy' || decoded.scope === 'pilot_tunnel') {
req.user = { username: 'node-proxy', role: 'admin', userId: 0 };
// Distributed License Enforcement: trust tier headers only from authenticated node proxy requests.
// Browser sessions and API tokens cannot set these; only a valid node_proxy JWT (signed with
// this instance's JWT secret) unlocks the trusted path.
const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
if (isLicenseTier(tierHeader)) {
req.proxyTier = normalizeTier(tierHeader);
}
if (isLicenseVariant(variantHeader)) {
req.proxyVariant = normalizeVariant(variantHeader);
} else if (variantHeader === '') {
req.proxyVariant = null;
}
next();
return;
}
// User session tokens: resolve against the database for up-to-date role and existence checks.
const dbUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
// User must exist in the database (rejects deleted users immediately)
if (!dbUser) {
res.status(401).json({ error: 'User account no longer exists' });
return;
}
// Token version check: rejects sessions after password change, role change, or admin reset.
// Pre-migration tokens (no tv claim) are accepted for backward compat and expire within 24h.
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
if (isDebugEnabled()) console.log('[Auth:diag] Token version mismatch for:', decoded.username, 'jwt:', decoded.tv, 'db:', dbUser.token_version);
console.log('[Auth] Session rejected: token version mismatch for:', decoded.username);
res.status(401).json({ error: 'Session invalidated. Please log in again.' });
return;
}
if (isDebugEnabled()) console.log('[Auth:diag] User resolved:', dbUser.username, 'role:', dbUser.role, 'tv:', dbUser.token_version);
// Use the DB role (not the JWT role) so role changes take effect immediately
req.user = { username: dbUser.username, role: dbUser.role as UserRole, userId: dbUser.id };
next();
} catch (err) {
console.error('[Auth] Token validation failed:', getErrorMessage(err, 'unknown'));
res.status(401).json({ error: 'Invalid or expired token' });
return;
}
};
/** Sign a session JWT and set it as an httpOnly cookie. */
export function issueSessionCookie(
res: Response,
req: Request,
user: { username: string; role: string; token_version: number },
jwtSecret: string,
): void {
const token = jwt.sign(
{ username: user.username, role: user.role, tv: user.token_version },
jwtSecret,
{ expiresIn: '24h' },
);
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
}
/**
* Sign a short-lived `mfa_pending` JWT and set it as an httpOnly cookie. This
* represents the partial-auth session that exists between password (or SSO)
* success and TOTP verification. The scope is enforced in `authMiddleware`, so
* this cookie cannot be used to reach any route other than
* `/api/auth/login/mfa` or `/api/auth/logout`.
*/
export function issueMfaPendingCookie(
res: Response,
req: Request,
user: { id: number; username: string },
jwtSecret: string,
opts: { sso?: boolean } = {},
): void {
const token = jwt.sign(
{ scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true },
jwtSecret,
{ expiresIn: Math.floor(MFA_PENDING_TTL_MS / 1000) },
);
res.cookie(MFA_PENDING_COOKIE_NAME, token, {
...getCookieOptions(req),
maxAge: MFA_PENDING_TTL_MS,
});
}
/** Clear the partial-auth cookie. Called on successful MFA verification and on logout. */
export function clearMfaPendingCookie(res: Response, req: Request): void {
res.clearCookie(MFA_PENDING_COOKIE_NAME, getCookieOptions(req));
}
+12 -17
View File
@@ -3,26 +3,21 @@ import { DatabaseService } from '../services/DatabaseService';
import { isDebugEnabled } from '../utils/debug';
import { getAuditSummary } from '../utils/audit-summaries';
import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns';
import { authMiddleware } from './auth';
/**
* Build the `/api/*` auth gate. Mounted at `/api`, so paths it sees are
* already stripped of the `/api` prefix. Exempts `/auth/*` (setup, login,
* SSO: handled by their own routes) and webhook triggers (authenticated
* via HMAC, not session).
*
* Takes `authMiddleware` as a dependency instead of importing it so this
* file does not pin the monolith's auth lifecycle. Phase 2 extracts
* `authMiddleware` into its own module.
* `/api/*` auth gate. Mounted at `/api`, so paths it sees are already
* stripped of the `/api` prefix. Exempts `/auth/*` (setup, login, SSO:
* handled by their own routes) and webhook triggers (authenticated via
* HMAC, not session).
*/
export function createAuthGate(authMiddleware: RequestHandler): RequestHandler {
return (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || WEBHOOK_TRIGGER_RE.test(req.path)) {
next();
return;
}
authMiddleware(req, res, next);
};
}
export const authGate: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || WEBHOOK_TRIGGER_RE.test(req.path)) {
next();
return;
}
authMiddleware(req, res, next);
};
/**
* Audit-logging middleware. Records every mutating `/api/*` action for