refactor(backend): extract auth/MFA/SSO routers from index.ts (phase 4a-2) (#735)

Second slice of Phase 4. Pulls the three auth-family route groups out of
index.ts into focused routers. All handlers move verbatim; index.ts drops
~845 lines.

New route files:
- routes/auth.ts: /api/auth core (status, setup, login, password, logout,
  check, generate-node-token)
- routes/mfa.ts: /api/auth/login/mfa + full /api/auth/mfa/* surface
  (status, enroll start/confirm, disable, backup-codes/regenerate,
  sso-bypass)
- routes/sso.ts: /api/auth/sso/{providers,ldap,oidc/:provider/authorize,
  oidc/:provider/callback} + getSSOBaseUrl helper. Module-load calls
  SSOService.getInstance().seedFromEnv() so env-seeded providers are
  available on the first request.

Shared lifts:
- helpers/constants.ts: MFA_REPLAY_TTL_MS + MFA_REPLAY_PURGE_INTERVAL_MS
  (used by mfa.ts and the startup purge timer in index.ts) and
  BCRYPT_SALT_ROUNDS (shared between setup and password-change handlers).
- middleware/auth.ts: new reissueSessionAfterTokenBump(req, res, userId)
  helper collapses three copies of "bump → fetch user → re-sign cookie"
  across auth.ts (password change) and mfa.ts (enrol confirm, disable).

Code review fixes:
- File-local requireEnrolledMfaUser helper in mfa.ts eliminates four
  copies of "auth check + rejectApiTokenScope + load enrolled MFA" with
  near-identical shape.
- Applied BCRYPT_SALT_ROUNDS to auth.ts setup + password handlers.

Mount order in index.ts: authRouter / mfaRouter / ssoRouter sit before
authGate because login / setup / SSO-callback are public; handlers that
need auth use authMiddleware directly on the route.
This commit is contained in:
Anso
2026-04-23 20:43:32 -04:00
committed by GitHub
parent 50e64b058b
commit b329916a0c
6 changed files with 885 additions and 858 deletions
+8
View File
@@ -7,6 +7,8 @@ export const PORT = 3000;
// Password policy
export const MIN_PASSWORD_LENGTH = 8;
/** bcrypt cost factor. 10 is current Sencho default; roughly ~75ms/hash on modern hardware. */
export const BCRYPT_SALT_ROUNDS = 10;
// Labels
export const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
@@ -20,6 +22,12 @@ export const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
export const MFA_PENDING_SCOPE = 'mfa_pending';
export const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
// MFA replay-prevention: recently-used codes are blacklisted for
// MFA_REPLAY_TTL_MS to block replay within a single 30-second TOTP window
// (plus drift tolerance). A periodic purge keeps the table bounded.
export const MFA_REPLAY_TTL_MS = 120 * 1000;
export const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000;
// Hot-path cache TTLs.
// Short TTLs collapse concurrent polling pressure across browser tabs and
// overlapping service samplers without introducing noticeable UI staleness.
+13 -858
View File
@@ -8,7 +8,7 @@ import bcrypt from 'bcrypt';
import crypto from 'crypto';
import si from 'systeminformation';
import path from 'path';
import { DatabaseService, Node, AuthProvider, ScheduledTask, UserRole, ResourceType, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService';
import { DatabaseService, Node, ScheduledTask, UserRole, ResourceType, parsePolicyEvaluation, type VulnerabilityScan } from './services/DatabaseService';
import { NotificationService } from './services/NotificationService';
import { MonitorService } from './services/MonitorService';
import { AutoHealService } from './services/AutoHealService';
@@ -24,8 +24,6 @@ import { FleetSyncService } from './services/FleetSyncService';
import { LicenseService } from './services/LicenseService';
import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { MfaService } from './services/MfaService';
import { CryptoService } from './services/CryptoService';
import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CacheService } from './services/CacheService';
@@ -36,14 +34,12 @@ import './types/express';
import {
PORT,
MIN_PASSWORD_LENGTH,
COOKIE_NAME,
MFA_PENDING_COOKIE_NAME,
MFA_PENDING_SCOPE,
MFA_REPLAY_TTL_MS,
MFA_REPLAY_PURGE_INTERVAL_MS,
STATS_CACHE_TTL_MS,
SYSTEM_STATS_CACHE_TTL_MS,
STACK_STATUSES_CACHE_TTL_MS,
} from './helpers/constants';
import { isSecureRequest } from './helpers/cookies';
import {
checkPermission,
requirePermission,
@@ -62,20 +58,13 @@ import {
} from './helpers/policyGate';
import {
webhookTriggerLimiter,
authRateLimiter,
ssoRateLimiter,
trivyInstallLimiter,
} from './middleware/rateLimiters';
import { authGate, auditLog } from './middleware/authGate';
import { enforceApiTokenScope } from './middleware/apiTokenScope';
import { errorHandler } from './middleware/errorHandler';
import { createApp } from './app';
import {
authMiddleware,
issueSessionCookie,
issueMfaPendingCookie,
clearMfaPendingCookie,
} from './middleware/auth';
import { authMiddleware } from './middleware/auth';
import { createRemoteProxyMiddleware } from './proxy/remoteNodeProxy';
import { createServer } from './server';
import { attachUpgrade } from './websocket/upgradeHandler';
@@ -84,6 +73,9 @@ import { FleetUpdateTrackerService } from './services/FleetUpdateTrackerService'
import { mintConsoleSession } from './helpers/consoleSession';
import { invalidateNodeCaches } from './helpers/cacheInvalidation';
import { metaRouter } from './routes/meta';
import { authRouter } from './routes/auth';
import { mfaRouter } from './routes/mfa';
import { ssoRouter } from './routes/sso';
import { licenseRouter, systemUpdateRouter, scheduleLocalUpdate } from './routes/license';
import { permissionsRouter } from './routes/permissions';
import { convertRouter } from './routes/convert';
@@ -133,850 +125,13 @@ const app = createApp();
// Public /api/health and /api/meta (no auth). Mounted before authGate.
app.use('/api', metaRouter);
// Auth Routes (no authentication required)
// Auth / MFA / SSO routers. Mounted before authGate because some paths are
// public (login, setup, SSO callbacks); handlers that need auth use the
// authMiddleware directly.
app.use('/api/auth', authRouter);
app.use('/api/auth', mfaRouter);
app.use('/api/auth/sso', ssoRouter);
// Check if setup is needed, and whether the caller currently holds a valid
// `mfa_pending` partial-auth cookie (so the frontend can route to the
// challenge screen on a page reload mid-flow, for example after an OIDC
// redirect).
app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
let mfaPending = false;
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
if (mfaCookie && settings.auth_jwt_secret) {
try {
const decoded = jwt.verify(mfaCookie, settings.auth_jwt_secret) as { scope?: string };
mfaPending = decoded.scope === MFA_PENDING_SCOPE;
} catch {
// Expired or invalid cookie; treat as no pending challenge.
}
}
res.json({ needsSetup, mfaPending });
} catch (error) {
console.error('Error checking setup status:', error);
res.json({ needsSetup: true, mfaPending: false });
}
});
// Initial setup endpoint
app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const dbSvc = DatabaseService.getInstance();
const settings = dbSvc.getGlobalSettings();
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
if (!needsSetup) {
res.status(400).json({ error: 'Setup has already been completed' });
return;
}
const { username, password, confirmPassword } = req.body;
// Validation
if (!username || !password || !confirmPassword) {
res.status(400).json({ error: 'All fields are required' });
return;
}
if (username.length < 3) {
res.status(400).json({ error: 'Username must be at least 3 characters' });
return;
}
if (password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
if (password !== confirmPassword) {
res.status(400).json({ error: 'Passwords do not match' });
return;
}
// Save credentials (this also generates the JWT secret)
const passwordHash = await bcrypt.hash(password, 10);
const jwtSecret = crypto.randomBytes(64).toString('hex');
dbSvc.updateGlobalSetting('auth_username', username);
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
// Create admin user in users table
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
// Issue JWT and log user in
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
res.json({ success: true, message: 'Setup completed successfully' });
} catch (error) {
console.error('Setup error:', error);
res.status(500).json({ error: 'Failed to complete setup' });
}
});
// Login endpoint
app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
try {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(username);
if (user) {
const isValid = await bcrypt.compare(password, user.password_hash);
if (isValid) {
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret missing from DB');
// If MFA is enabled for this user, issue only the partial-auth cookie
// and signal the client to complete the TOTP challenge. No session
// cookie is set until the second factor is verified.
const mfa = db.getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
}
if (mfa?.enabled) {
issueMfaPendingCookie(res, req, user, jwtSecret);
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] Login successful:', user.username);
res.json({ success: true, message: 'Login successful' });
return;
}
}
console.warn('[Auth] Login failed for username:', username);
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Update password endpoint - any authenticated user can change their own password
app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot change passwords.', code: 'SCOPE_DENIED' });
return;
}
try {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
res.status(400).json({ error: 'Old password and new password are required' });
return;
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
const dbSvc = DatabaseService.getInstance();
const user = dbSvc.getUserByUsername(req.user!.username);
if (!user) {
res.status(400).json({ error: 'User not found' });
return;
}
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
if (!isValid) {
res.status(401).json({ error: 'Invalid old password' });
return;
}
const newHash = await bcrypt.hash(newPassword, 10);
dbSvc.updateUser(user.id, { password_hash: newHash });
// Keep global_settings in sync for backward compat
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
// Invalidate all other sessions for this user
dbSvc.bumpTokenVersion(user.id);
// Re-issue cookie with new token version so the current session survives
const settings = dbSvc.getGlobalSettings();
const updatedUser = dbSvc.getUserById(user.id);
if (settings.auth_jwt_secret && updatedUser) {
issueSessionCookie(res, req, updatedUser, settings.auth_jwt_secret);
}
console.log('[Auth] Password changed by:', req.user!.username);
res.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
console.error('[Auth] Password update error:', error);
res.status(500).json({ error: 'Failed to update password' });
}
});
app.post('/api/auth/logout', (req: Request, res: Response): void => {
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict',
});
// Also clear any partial-auth cookie so a user aborting the MFA challenge
// is returned to a fully unauthenticated state.
clearMfaPendingCookie(res, req);
res.json({ success: true, message: 'Logged out successfully' });
});
// Check authentication status
app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void => {
res.json({ authenticated: true, user: req.user });
});
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication
app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot generate node tokens.', code: 'SCOPE_DENIED' });
return;
}
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
return;
}
// Default 1-year expiry; admin should rotate tokens periodically.
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' });
res.json({ token });
} catch (error: any) {
res.status(500).json({ error: error.message || 'Failed to generate node token' });
}
});
// --- SSO Auth Routes (public, under /api/auth/sso/*) ---
// Seed SSO config from environment variables on startup
SSOService.getInstance().seedFromEnv();
/** Derive the OAuth callback base URL from SSO_CALLBACK_URL or the request Host header, with injection validation. */
function getSSOBaseUrl(req: Request, res: Response): string | null {
const host = req.get('host') || '';
if (!process.env.SSO_CALLBACK_URL && /[\s<>\r\n]/.test(host)) {
console.error('[SSO] Rejected suspicious Host header');
res.redirect('/?sso_error=Invalid+request');
return null;
}
if (!process.env.SSO_CALLBACK_URL && isDebugEnabled()) {
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', host);
}
return process.env.SSO_CALLBACK_URL || `${req.protocol}://${host}`;
}
// List enabled SSO providers (for login page)
app.get('/api/auth/sso/providers', (_req: Request, res: Response): void => {
try {
const providers = SSOService.getInstance().getEnabledProviders();
res.json(providers);
} catch (e) {
console.warn('[SSO] Failed to list enabled providers, returning empty list:', (e as Error).message);
res.json([]);
}
});
// LDAP login
app.post('/api/auth/sso/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
const result = await SSOService.getInstance().authenticateLDAP(username, password);
if (!result.success || !result.user) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
// Provision or find existing user
const user = SSOService.getInstance().provisionUser({
authProvider: 'ldap',
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
// Issue JWT (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
// If MFA is enabled AND the user has opted into SSO enforcement, route
// through the TOTP challenge. Otherwise SSO bypasses MFA (default).
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
console.log(`[SSO] LDAP login successful: ${user.username}`);
res.json({ success: true, message: 'Login successful' });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'LDAP login failed';
console.error('[SSO] LDAP login error:', msg);
res.status(500).json({ error: msg });
}
});
// OIDC: Initiate authorization flow
app.get('/api/auth/sso/oidc/:provider/authorize', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
if (!validProviders.includes(provider)) {
res.status(400).json({ error: 'Invalid SSO provider' });
return;
}
const baseUrl = getSSOBaseUrl(req, res);
if (!baseUrl) return;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const { url, state, codeVerifier } = await SSOService.getInstance().getOIDCAuthorizationUrl(provider, callbackUrl);
// Store state + codeVerifier in an encrypted short-lived cookie
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
const statePayload = JSON.stringify({ state, codeVerifier, provider });
res.cookie('sencho_sso_state', cryptoSvc.encrypt(statePayload), {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'lax', // Must be lax for cross-site IdP redirect
maxAge: 5 * 60 * 1000, // 5 minutes
});
res.redirect(url);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'SSO initialization failed';
console.error('[SSO] OIDC authorize error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});
// OIDC: Callback from identity provider
app.get('/api/auth/sso/oidc/:provider/callback', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const code = String(req.query.code || '');
const state = String(req.query.state || '');
const oidcError = req.query.error ? String(req.query.error) : '';
const error_description = req.query.error_description ? String(req.query.error_description) : '';
if (oidcError) {
res.redirect(`/?sso_error=${encodeURIComponent(error_description || oidcError)}`);
return;
}
if (!code || !state) {
res.redirect('/?sso_error=Missing+authorization+code');
return;
}
// Read and validate state cookie
const stateCookie = req.cookies?.sencho_sso_state;
// Always clear the one-time state cookie, regardless of outcome
res.clearCookie('sencho_sso_state', { httpOnly: true, secure: isSecureRequest(req), sameSite: 'lax' });
if (!stateCookie) {
res.redirect('/?sso_error=SSO+session+expired.+Please+try+again.');
return;
}
const cryptoSvc = (await import('./services/CryptoService')).CryptoService.getInstance();
let statePayload: { state: string; codeVerifier: string; provider: string };
try {
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
} catch (e) {
console.error('[SSO] Failed to decrypt SSO state cookie:', (e as Error).message);
res.redirect('/?sso_error=Invalid+SSO+session');
return;
}
if (statePayload.provider !== provider) {
res.redirect(`/?sso_error=${encodeURIComponent(`Provider mismatch: expected ${statePayload.provider}, got ${provider}`)}`);
return;
}
const baseUrl = getSSOBaseUrl(req, res);
if (!baseUrl) return;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const result = await SSOService.getInstance().handleOIDCCallback(
provider, callbackUrl,
{ code, state },
statePayload.state,
statePayload.codeVerifier
);
if (!result.success || !result.user) {
res.redirect(`/?sso_error=${encodeURIComponent(result.error || 'Authentication failed')}`);
return;
}
// Provision or find existing user
const user = SSOService.getInstance().provisionUser({
authProvider: provider as AuthProvider,
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
// Issue JWT + cookie (same as local login)
const settings = DatabaseService.getInstance().getGlobalSettings();
// If MFA is enabled AND the user has opted into SSO enforcement, set only
// the partial-auth cookie. The frontend surfaces the challenge screen
// based on `/api/auth/status` after the redirect lands.
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=oidc provider=', provider, 'user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] OIDC login password OK, MFA challenge pending: ${user.username} via ${provider}`);
res.redirect('/');
return;
}
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
console.log(`[SSO] OIDC login successful: ${user.username} via ${provider}`);
res.redirect('/');
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'SSO callback failed';
console.error('[SSO] OIDC callback error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});
// --- MFA (TOTP) Routes ---
const MFA_MAX_FAILED = 5;
const MFA_LOCKOUT_MS = 15 * 60 * 1000;
const MFA_REPLAY_TTL_MS = 120 * 1000;
const MFA_REPLAY_PURGE_INTERVAL_MS = 60 * 1000;
/**
* Complete the second factor of login. Consumes the short-lived
* `sencho_mfa_pending` cookie and, on success, clears it and issues a full
* session cookie. Accepts either a 6-digit TOTP or one of the user's backup
* codes (single-use). Enforces per-user failure counter and lockout.
*/
app.post('/api/auth/login/mfa', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const startedAt = Date.now();
try {
const db = DatabaseService.getInstance();
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'Server is not configured' });
return;
}
const pendingCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
if (!pendingCookie) {
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: no pending cookie');
res.status(401).json({ error: 'No pending two-factor challenge. Please sign in again.' });
return;
}
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean };
try {
decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded;
} catch {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: pending cookie expired or invalid');
res.status(401).json({ error: 'Two-factor challenge expired. Please sign in again.' });
return;
}
if (decoded.scope !== MFA_PENDING_SCOPE || typeof decoded.user_id !== 'number') {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: bad cookie scope=', decoded.scope, 'userId=', decoded.user_id);
res.status(401).json({ error: 'Invalid two-factor challenge' });
return;
}
const user = db.getUserById(decoded.user_id);
const mfa = db.getUserMfa(decoded.user_id);
if (!user || !mfa?.enabled || !mfa.totp_secret_encrypted) {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: mfa not configured for userId=', decoded.user_id);
res.status(401).json({ error: 'Two-factor authentication is not configured' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] login/mfa: entry user=', user.username, 'sso=', !!decoded.sso, 'failedAttempts=', mfa.failed_attempts, 'lockedUntil=', mfa.locked_until ?? null, 'lockedRemainingMs=', mfa.locked_until ? Math.max(0, mfa.locked_until - Date.now()) : 0);
}
// Lockout check
if (mfa.locked_until && mfa.locked_until > Date.now()) {
const retryAfter = Math.ceil((mfa.locked_until - Date.now()) / 1000);
res.setHeader('Retry-After', String(retryAfter));
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: rejected (locked) user=', user.username, 'retryAfter=', retryAfter);
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter });
return;
}
const rawCode = typeof req.body?.code === 'string' ? req.body.code : '';
const isBackup = req.body?.isBackupCode === true;
if (!rawCode) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const cryptoSvc = CryptoService.getInstance();
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
let verified = false;
if (isBackup) {
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
const bcryptStart = Date.now();
const result = await MfaService.verifyBackupCode(hashes, rawCode);
const bcryptMs = Date.now() - bcryptStart;
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=backup user=', user.username, 'matched=', result.matched, 'bcryptMs=', bcryptMs, 'hashesChecked=', hashes.length);
if (bcryptMs > 500) console.warn('[MFA] Slow backup-code verify for user=', user.username, 'durationMs=', bcryptMs);
if (result.matched) {
db.upsertUserMfa(decoded.user_id, { backup_codes_json: JSON.stringify(result.remainingHashes) });
verified = true;
}
} else {
const trimmed = rawCode.trim().replace(/\s+/g, '');
const totpOk = MfaService.verifyTotp(secret, trimmed);
const window = MfaService.currentWindow();
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=totp user=', user.username, 'formatOk=', /^\d{6}$/.test(trimmed), 'totpOk=', totpOk, 'window=', window);
if (totpOk) {
if (db.isMfaCodeUsed(decoded.user_id, trimmed, window)) {
db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: replay rejected user=', user.username, 'window=', window);
res.status(401).json({ error: 'This code was already used. Please wait for the next one.', code: 'OTP_REPLAY' });
return;
}
db.markMfaCodeUsed(decoded.user_id, trimmed, window);
verified = true;
}
}
if (!verified) {
const failedCount = db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: verify failed user=', user.username, 'failedCount=', failedCount, 'lockoutThreshold=', MFA_MAX_FAILED);
if (failedCount >= MFA_MAX_FAILED) {
const lockedUntil = Date.now() + MFA_LOCKOUT_MS;
db.lockMfa(decoded.user_id, lockedUntil);
res.setHeader('Retry-After', String(Math.ceil(MFA_LOCKOUT_MS / 1000)));
console.warn('[MFA] Lockout engaged: user=', user.username, 'lockedUntil=', new Date(lockedUntil).toISOString());
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter: Math.ceil(MFA_LOCKOUT_MS / 1000) });
return;
}
res.status(401).json({ error: 'Invalid verification code' });
return;
}
db.clearMfaFailures(decoded.user_id);
clearMfaPendingCookie(res, req);
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] MFA challenge cleared:', user.username);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt);
res.json({ success: true });
} catch (error: unknown) {
console.error('[Auth] MFA verification error:', (error as Error).message);
res.status(500).json({ error: 'Two-factor verification failed' });
}
});
/** Report the current user's MFA state. Used by the Account settings UI. */
app.get('/api/auth/mfa/status', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
const hashes: string[] = mfa?.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
res.json({
enabled: mfa?.enabled === 1,
backupCodesRemaining: hashes.length,
sso_enforce_mfa: mfa?.sso_enforce_mfa === 1,
});
} catch (error: unknown) {
console.error('[MFA] status error:', (error as Error).message);
res.status(500).json({ error: 'Failed to load MFA status' });
}
});
/**
* Begin enrolment: generate a fresh TOTP secret, store it encrypted with
* `enabled=0`, and return the otpauth URI plus the raw base32 secret so the
* frontend can render a QR code and the manual-entry fallback.
*/
app.post('/api/auth/mfa/enroll/start', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const db = DatabaseService.getInstance();
const existing = db.getUserMfa(req.user.userId);
if (existing?.enabled) {
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/start user=', req.user.username, 'hadPendingSecret=', Boolean(existing?.totp_secret_encrypted));
}
const secret = MfaService.generateSecret();
const cryptoSvc = CryptoService.getInstance();
db.upsertUserMfa(req.user.userId, {
enabled: false,
totp_secret_encrypted: cryptoSvc.encrypt(secret),
backup_codes_json: null,
failed_attempts: 0,
locked_until: null,
});
const otpauthUri = MfaService.buildOtpauthUri(secret, req.user.username);
res.json({ otpauthUri, secret });
} catch (error: unknown) {
console.error('[MFA] enroll start error:', (error as Error).message);
res.status(500).json({ error: 'Failed to start enrolment' });
}
});
/**
* Finalise enrolment: verify the user's first TOTP against the pending
* secret, flip `enabled=1`, generate + hash + return the backup codes ONCE,
* and bump `token_version` so any other sessions re-authenticate.
*/
app.post('/api/auth/mfa/enroll/confirm', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const code = typeof req.body?.code === 'string' ? req.body.code : '';
if (!code) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.totp_secret_encrypted) {
res.status(400).json({ error: 'No enrolment in progress. Start enrolment first.' });
return;
}
if (mfa.enabled) {
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
return;
}
const cryptoSvc = CryptoService.getInstance();
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
if (!MfaService.verifyTotp(secret, code)) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
const backupCodes = MfaService.generateBackupCodes();
const hashes = await MfaService.hashBackupCodes(backupCodes);
db.upsertUserMfa(req.user.userId, {
enabled: true,
backup_codes_json: JSON.stringify(hashes),
failed_attempts: 0,
locked_until: null,
});
db.bumpTokenVersion(req.user.userId);
// The token_version bump invalidates the caller's current session cookie.
// Since the user has just proven possession of the TOTP secret, re-issue a
// session cookie that carries the new token_version so they stay signed in
// long enough to see and save the backup codes.
const refreshed = db.getUserById(req.user.userId);
const settings = db.getGlobalSettings();
if (refreshed && settings.auth_jwt_secret) {
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
}
console.log('[MFA] Enrolment completed:', req.user.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/confirm backupCodesIssued=', backupCodes.length, 'user=', req.user.username);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error: unknown) {
console.error('[MFA] enroll confirm error:', (error as Error).message);
res.status(500).json({ error: 'Failed to confirm enrolment' });
}
});
/**
* Disable MFA for the current user. Requires a valid TOTP or backup code to
* prove possession, so a stolen session cookie alone cannot turn off the
* second factor. Bumps `token_version` on success.
*/
app.post('/api/auth/mfa/disable', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const code = typeof req.body?.code === 'string' ? req.body.code : '';
const isBackup = req.body?.isBackupCode === true;
if (!code) {
res.status(400).json({ error: 'A verification code is required to disable two-factor authentication' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
return;
}
let ok = false;
if (isBackup) {
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
ok = (await MfaService.verifyBackupCode(hashes, code)).matched;
} else {
const cryptoSvc = CryptoService.getInstance();
ok = MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code);
}
if (isDebugEnabled()) {
console.log('[MFA:diag] disable user=', req.user.username, 'codeType=', isBackup ? 'backup' : 'totp', 'verified=', ok);
}
if (!ok) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
db.deleteUserMfa(req.user.userId);
db.bumpTokenVersion(req.user.userId);
// Re-issue the session cookie so the user stays signed in after the bump.
// They just proved possession of a current factor, so granting them the
// new token_version is safe and avoids a surprising forced re-login.
const refreshed = db.getUserById(req.user.userId);
const settings = db.getGlobalSettings();
if (refreshed && settings.auth_jwt_secret) {
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
}
console.log('[MFA] Disabled by user:', req.user.username);
res.json({ success: true });
} catch (error: unknown) {
console.error('[MFA] disable error:', (error as Error).message);
res.status(500).json({ error: 'Failed to disable two-factor authentication' });
}
});
/**
* Regenerate backup codes. Requires a valid TOTP so a stolen session alone
* cannot print new codes. The old set is invalidated immediately; the new
* set is returned in cleartext ONCE.
*/
app.post('/api/auth/mfa/backup-codes/regenerate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const code = typeof req.body?.code === 'string' ? req.body.code : '';
if (!code) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
return;
}
const cryptoSvc = CryptoService.getInstance();
if (!MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted), code)) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
const backupCodes = MfaService.generateBackupCodes();
const hashes = await MfaService.hashBackupCodes(backupCodes);
db.upsertUserMfa(req.user.userId, { backup_codes_json: JSON.stringify(hashes) });
console.log('[MFA] Backup codes regenerated:', req.user.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] backup-codes/regenerate user=', req.user.username, 'codesIssued=', backupCodes.length);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error: unknown) {
console.error('[MFA] regenerate backup codes error:', (error as Error).message);
res.status(500).json({ error: 'Failed to regenerate backup codes' });
}
});
/** Toggle whether SSO logins must also complete the TOTP challenge. */
app.put('/api/auth/mfa/sso-bypass', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage MFA.', code: 'SCOPE_DENIED' });
return;
}
const enforce = req.body?.enforce === true;
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.enabled) {
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
return;
}
if ((mfa.sso_enforce_mfa === 1) !== enforce) {
db.upsertUserMfa(req.user.userId, { sso_enforce_mfa: enforce });
console.log('[MFA] SSO bypass toggled:', req.user.username, 'enforce=', enforce);
}
res.json({ success: true, sso_enforce_mfa: enforce });
} catch (error: unknown) {
console.error('[MFA] sso-bypass error:', (error as Error).message);
res.status(500).json({ error: 'Failed to update SSO enforcement' });
}
});
// Auth gate on all /api/* routes (exempts /auth/* and webhook triggers).
app.use('/api', authGate);
+16
View File
@@ -198,3 +198,19 @@ export function issueMfaPendingCookie(
export function clearMfaPendingCookie(res: Response, req: Request): void {
res.clearCookie(MFA_PENDING_COOKIE_NAME, getCookieOptions(req));
}
/**
* Re-issue the session cookie after bumping `token_version`. Routes that
* call `bumpTokenVersion` (password change, MFA enrol, MFA disable) use this
* so the caller stays signed in after their previous cookie is invalidated.
* No-ops silently when the JWT secret is missing or the user has been
* deleted mid-request.
*/
export function reissueSessionAfterTokenBump(req: Request, res: Response, userId: number): void {
const db = DatabaseService.getInstance();
const refreshed = db.getUserById(userId);
const settings = db.getGlobalSettings();
if (refreshed && settings.auth_jwt_secret) {
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
}
}
+231
View File
@@ -0,0 +1,231 @@
import { Router, type Request, type Response } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { DatabaseService } from '../services/DatabaseService';
import {
authMiddleware,
issueSessionCookie,
issueMfaPendingCookie,
clearMfaPendingCookie,
reissueSessionAfterTokenBump,
} from '../middleware/auth';
import { authRateLimiter } from '../middleware/rateLimiters';
import { requireAdmin } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import {
BCRYPT_SALT_ROUNDS,
COOKIE_NAME,
MFA_PENDING_COOKIE_NAME,
MFA_PENDING_SCOPE,
MIN_PASSWORD_LENGTH,
} from '../helpers/constants';
import { isSecureRequest } from '../helpers/cookies';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
export const authRouter = Router();
// Check if setup is needed, and whether the caller currently holds a valid
// `mfa_pending` partial-auth cookie (so the frontend can route to the
// challenge screen on a page reload mid-flow, e.g. after an OIDC redirect).
authRouter.get('/status', async (req: Request, res: Response): Promise<void> => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
let mfaPending = false;
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
if (mfaCookie && settings.auth_jwt_secret) {
try {
const decoded = jwt.verify(mfaCookie, settings.auth_jwt_secret) as { scope?: string };
mfaPending = decoded.scope === MFA_PENDING_SCOPE;
} catch {
// Expired or invalid cookie; treat as no pending challenge.
}
}
res.json({ needsSetup, mfaPending });
} catch (error) {
console.error('Error checking setup status:', error);
res.json({ needsSetup: true, mfaPending: false });
}
});
// Initial setup endpoint
authRouter.post('/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const dbSvc = DatabaseService.getInstance();
const settings = dbSvc.getGlobalSettings();
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
if (!needsSetup) {
res.status(400).json({ error: 'Setup has already been completed' });
return;
}
const { username, password, confirmPassword } = req.body;
if (!username || !password || !confirmPassword) {
res.status(400).json({ error: 'All fields are required' });
return;
}
if (username.length < 3) {
res.status(400).json({ error: 'Username must be at least 3 characters' });
return;
}
if (password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
if (password !== confirmPassword) {
res.status(400).json({ error: 'Passwords do not match' });
return;
}
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const jwtSecret = crypto.randomBytes(64).toString('hex');
dbSvc.updateGlobalSetting('auth_username', username);
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
// Create admin user in users table
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
res.json({ success: true, message: 'Setup completed successfully' });
} catch (error) {
console.error('Setup error:', error);
res.status(500).json({ error: 'Failed to complete setup' });
}
});
// Login endpoint
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
try {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(username);
if (user) {
const isValid = await bcrypt.compare(password, user.password_hash);
if (isValid) {
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret missing from DB');
// If MFA is enabled for this user, issue only the partial-auth cookie
// and signal the client to complete the TOTP challenge. No session
// cookie is set until the second factor is verified.
const mfa = db.getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
}
if (mfa?.enabled) {
issueMfaPendingCookie(res, req, user, jwtSecret);
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] Login successful:', user.username);
res.json({ success: true, message: 'Login successful' });
return;
}
}
console.warn('[Auth] Login failed for username:', username);
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Any authenticated user can change their own password.
authRouter.put('/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (rejectApiTokenScope(req, res, 'API tokens cannot change passwords.')) return;
try {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
res.status(400).json({ error: 'Old password and new password are required' });
return;
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
const dbSvc = DatabaseService.getInstance();
const user = dbSvc.getUserByUsername(req.user!.username);
if (!user) {
res.status(400).json({ error: 'User not found' });
return;
}
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
if (!isValid) {
res.status(401).json({ error: 'Invalid old password' });
return;
}
const newHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
dbSvc.updateUser(user.id, { password_hash: newHash });
// Keep global_settings in sync for backward compat.
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
// Invalidate all other sessions for this user, then re-issue the caller's
// cookie so the current session survives the token-version bump.
dbSvc.bumpTokenVersion(user.id);
reissueSessionAfterTokenBump(req, res, user.id);
console.log('[Auth] Password changed by:', req.user!.username);
res.json({ success: true, message: 'Password updated successfully' });
} catch (error) {
console.error('[Auth] Password update error:', error);
res.status(500).json({ error: 'Failed to update password' });
}
});
authRouter.post('/logout', (req: Request, res: Response): void => {
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict',
});
// Clear any partial-auth cookie so a user aborting the MFA challenge is
// returned to a fully unauthenticated state.
clearMfaPendingCookie(res, req);
res.json({ success: true, message: 'Logged out successfully' });
});
authRouter.get('/check', authMiddleware, (req: Request, res: Response): void => {
res.json({ authenticated: true, user: req.user });
});
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication.
authRouter.post('/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (rejectApiTokenScope(req, res, 'API tokens cannot generate node tokens.')) return;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
return;
}
// Default 1-year expiry; admin should rotate tokens periodically.
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' });
res.json({ token });
} catch (error) {
res.status(500).json({ error: getErrorMessage(error, 'Failed to generate node token') });
}
});
+405
View File
@@ -0,0 +1,405 @@
import { Router, type Request, type Response } from 'express';
import jwt from 'jsonwebtoken';
import { DatabaseService, type UserMfa } from '../services/DatabaseService';
import { MfaService } from '../services/MfaService';
import { CryptoService } from '../services/CryptoService';
import {
authMiddleware,
issueSessionCookie,
clearMfaPendingCookie,
reissueSessionAfterTokenBump,
} from '../middleware/auth';
import { authRateLimiter } from '../middleware/rateLimiters';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import {
MFA_PENDING_COOKIE_NAME,
MFA_PENDING_SCOPE,
} from '../helpers/constants';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
// Lockout: MFA_MAX_FAILED bad verifications in a row lock the account for
// MFA_LOCKOUT_MS. Used only in the login/mfa handler; other endpoints
// require a fresh TOTP/backup code but don't count toward the lockout.
const MFA_MAX_FAILED = 5;
const MFA_LOCKOUT_MS = 15 * 60 * 1000;
const MFA_SCOPE_MESSAGE = 'API tokens cannot manage MFA.';
/**
* Gate an MFA management endpoint: require an authenticated user session
* (not an API token) with MFA already enabled + a stored TOTP secret.
* Writes the appropriate error response and returns null on any failure;
* callers should early-return when null is returned.
*/
function requireEnrolledMfaUser(req: Request, res: Response): { userId: number; mfa: UserMfa } | null {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return null;
}
if (rejectApiTokenScope(req, res, MFA_SCOPE_MESSAGE)) return null;
const mfa = DatabaseService.getInstance().getUserMfa(req.user.userId);
if (!mfa?.enabled || !mfa.totp_secret_encrypted) {
res.status(400).json({ error: 'Two-factor authentication is not enabled' });
return null;
}
return { userId: req.user.userId, mfa };
}
export const mfaRouter = Router();
/**
* Complete the second factor of login. Consumes the short-lived
* `sencho_mfa_pending` cookie and, on success, clears it and issues a full
* session cookie. Accepts either a 6-digit TOTP or one of the user's backup
* codes (single-use). Enforces per-user failure counter and lockout.
*/
mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const startedAt = Date.now();
try {
const db = DatabaseService.getInstance();
const settings = db.getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'Server is not configured' });
return;
}
const pendingCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
if (!pendingCookie) {
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: no pending cookie');
res.status(401).json({ error: 'No pending two-factor challenge. Please sign in again.' });
return;
}
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean };
try {
decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded;
} catch {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: pending cookie expired or invalid');
res.status(401).json({ error: 'Two-factor challenge expired. Please sign in again.' });
return;
}
if (decoded.scope !== MFA_PENDING_SCOPE || typeof decoded.user_id !== 'number') {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: bad cookie scope=', decoded.scope, 'userId=', decoded.user_id);
res.status(401).json({ error: 'Invalid two-factor challenge' });
return;
}
const user = db.getUserById(decoded.user_id);
const mfa = db.getUserMfa(decoded.user_id);
if (!user || !mfa?.enabled || !mfa.totp_secret_encrypted) {
clearMfaPendingCookie(res, req);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: mfa not configured for userId=', decoded.user_id);
res.status(401).json({ error: 'Two-factor authentication is not configured' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] login/mfa: entry user=', user.username, 'sso=', !!decoded.sso, 'failedAttempts=', mfa.failed_attempts, 'lockedUntil=', mfa.locked_until ?? null, 'lockedRemainingMs=', mfa.locked_until ? Math.max(0, mfa.locked_until - Date.now()) : 0);
}
if (mfa.locked_until && mfa.locked_until > Date.now()) {
const retryAfter = Math.ceil((mfa.locked_until - Date.now()) / 1000);
res.setHeader('Retry-After', String(retryAfter));
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: rejected (locked) user=', user.username, 'retryAfter=', retryAfter);
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter });
return;
}
const rawCode = typeof req.body?.code === 'string' ? req.body.code : '';
const isBackup = req.body?.isBackupCode === true;
if (!rawCode) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const cryptoSvc = CryptoService.getInstance();
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
let verified = false;
if (isBackup) {
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
const bcryptStart = Date.now();
const result = await MfaService.verifyBackupCode(hashes, rawCode);
const bcryptMs = Date.now() - bcryptStart;
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=backup user=', user.username, 'matched=', result.matched, 'bcryptMs=', bcryptMs, 'hashesChecked=', hashes.length);
if (bcryptMs > 500) console.warn('[MFA] Slow backup-code verify for user=', user.username, 'durationMs=', bcryptMs);
if (result.matched) {
db.upsertUserMfa(decoded.user_id, { backup_codes_json: JSON.stringify(result.remainingHashes) });
verified = true;
}
} else {
const trimmed = rawCode.trim().replace(/\s+/g, '');
const totpOk = MfaService.verifyTotp(secret, trimmed);
const window = MfaService.currentWindow();
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: branch=totp user=', user.username, 'formatOk=', /^\d{6}$/.test(trimmed), 'totpOk=', totpOk, 'window=', window);
if (totpOk) {
if (db.isMfaCodeUsed(decoded.user_id, trimmed, window)) {
db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: replay rejected user=', user.username, 'window=', window);
res.status(401).json({ error: 'This code was already used. Please wait for the next one.', code: 'OTP_REPLAY' });
return;
}
db.markMfaCodeUsed(decoded.user_id, trimmed, window);
verified = true;
}
}
if (!verified) {
const failedCount = db.recordMfaFailure(decoded.user_id);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: verify failed user=', user.username, 'failedCount=', failedCount, 'lockoutThreshold=', MFA_MAX_FAILED);
if (failedCount >= MFA_MAX_FAILED) {
const lockedUntil = Date.now() + MFA_LOCKOUT_MS;
db.lockMfa(decoded.user_id, lockedUntil);
res.setHeader('Retry-After', String(Math.ceil(MFA_LOCKOUT_MS / 1000)));
console.warn('[MFA] Lockout engaged: user=', user.username, 'lockedUntil=', new Date(lockedUntil).toISOString());
res.status(423).json({ error: 'Too many failed attempts. Try again later.', retryAfter: Math.ceil(MFA_LOCKOUT_MS / 1000) });
return;
}
res.status(401).json({ error: 'Invalid verification code' });
return;
}
db.clearMfaFailures(decoded.user_id);
clearMfaPendingCookie(res, req);
issueSessionCookie(res, req, user, jwtSecret);
console.log('[Auth] MFA challenge cleared:', user.username);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt);
res.json({ success: true });
} catch (error) {
console.error('[Auth] MFA verification error:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Two-factor verification failed' });
}
});
/** Report the current user's MFA state. Used by the Account settings UI. */
mfaRouter.get('/mfa/status', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
const hashes: string[] = mfa?.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
res.json({
enabled: mfa?.enabled === 1,
backupCodesRemaining: hashes.length,
sso_enforce_mfa: mfa?.sso_enforce_mfa === 1,
});
} catch (error) {
console.error('[MFA] status error:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Failed to load MFA status' });
}
});
/**
* Begin enrolment: generate a fresh TOTP secret, store it encrypted with
* `enabled=0`, and return the otpauth URI plus the raw base32 secret so the
* frontend can render a QR code and the manual-entry fallback.
*/
mfaRouter.post('/mfa/enroll/start', authMiddleware, (req: Request, res: Response): void => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (rejectApiTokenScope(req, res, MFA_SCOPE_MESSAGE)) return;
const db = DatabaseService.getInstance();
const existing = db.getUserMfa(req.user.userId);
if (existing?.enabled) {
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
return;
}
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/start user=', req.user.username, 'hadPendingSecret=', Boolean(existing?.totp_secret_encrypted));
}
const secret = MfaService.generateSecret();
const cryptoSvc = CryptoService.getInstance();
db.upsertUserMfa(req.user.userId, {
enabled: false,
totp_secret_encrypted: cryptoSvc.encrypt(secret),
backup_codes_json: null,
failed_attempts: 0,
locked_until: null,
});
const otpauthUri = MfaService.buildOtpauthUri(secret, req.user.username);
res.json({ otpauthUri, secret });
} catch (error) {
console.error('[MFA] enroll start error:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Failed to start enrolment' });
}
});
/**
* Finalise enrolment: verify the user's first TOTP against the pending
* secret, flip `enabled=1`, generate + hash + return the backup codes ONCE,
* and bump `token_version` so any other sessions re-authenticate.
*/
mfaRouter.post('/mfa/enroll/confirm', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'Authentication required' });
return;
}
if (rejectApiTokenScope(req, res, MFA_SCOPE_MESSAGE)) return;
const code = typeof req.body?.code === 'string' ? req.body.code : '';
if (!code) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const db = DatabaseService.getInstance();
const mfa = db.getUserMfa(req.user.userId);
if (!mfa?.totp_secret_encrypted) {
res.status(400).json({ error: 'No enrolment in progress. Start enrolment first.' });
return;
}
if (mfa.enabled) {
res.status(409).json({ error: 'Two-factor authentication is already enabled' });
return;
}
const cryptoSvc = CryptoService.getInstance();
const secret = cryptoSvc.decrypt(mfa.totp_secret_encrypted);
if (!MfaService.verifyTotp(secret, code)) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
const backupCodes = MfaService.generateBackupCodes();
const hashes = await MfaService.hashBackupCodes(backupCodes);
db.upsertUserMfa(req.user.userId, {
enabled: true,
backup_codes_json: JSON.stringify(hashes),
failed_attempts: 0,
locked_until: null,
});
db.bumpTokenVersion(req.user.userId);
// The bump invalidates the caller's cookie; they just proved TOTP
// possession so they can be resealed under the new token_version.
reissueSessionAfterTokenBump(req, res, req.user.userId);
console.log('[MFA] Enrolment completed:', req.user.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] enroll/confirm backupCodesIssued=', backupCodes.length, 'user=', req.user.username);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error) {
console.error('[MFA] enroll confirm error:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Failed to confirm enrolment' });
}
});
/**
* Disable MFA for the current user. Requires a valid TOTP or backup code to
* prove possession, so a stolen session cookie alone cannot turn off the
* second factor. Bumps `token_version` on success.
*/
mfaRouter.post('/mfa/disable', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const gate = requireEnrolledMfaUser(req, res);
if (!gate) return;
const { userId, mfa } = gate;
const code = typeof req.body?.code === 'string' ? req.body.code : '';
const isBackup = req.body?.isBackupCode === true;
if (!code) {
res.status(400).json({ error: 'A verification code is required to disable two-factor authentication' });
return;
}
let ok = false;
if (isBackup) {
const hashes: string[] = mfa.backup_codes_json ? JSON.parse(mfa.backup_codes_json) : [];
ok = (await MfaService.verifyBackupCode(hashes, code)).matched;
} else {
const cryptoSvc = CryptoService.getInstance();
ok = MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted!), code);
}
if (isDebugEnabled()) {
console.log('[MFA:diag] disable user=', req.user!.username, 'codeType=', isBackup ? 'backup' : 'totp', 'verified=', ok);
}
if (!ok) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
const db = DatabaseService.getInstance();
db.deleteUserMfa(userId);
db.bumpTokenVersion(userId);
// The user just proved possession of a current factor, so reseal their
// cookie under the new token_version to avoid a surprising forced re-login.
reissueSessionAfterTokenBump(req, res, userId);
console.log('[MFA] Disabled by user:', req.user!.username);
res.json({ success: true });
} catch (error) {
console.error('[MFA] disable error:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Failed to disable two-factor authentication' });
}
});
/**
* Regenerate backup codes. Requires a valid TOTP so a stolen session alone
* cannot print new codes. The old set is invalidated immediately; the new
* set is returned in cleartext ONCE.
*/
mfaRouter.post('/mfa/backup-codes/regenerate', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const gate = requireEnrolledMfaUser(req, res);
if (!gate) return;
const { userId, mfa } = gate;
const code = typeof req.body?.code === 'string' ? req.body.code : '';
if (!code) {
res.status(400).json({ error: 'A verification code is required' });
return;
}
const cryptoSvc = CryptoService.getInstance();
if (!MfaService.verifyTotp(cryptoSvc.decrypt(mfa.totp_secret_encrypted!), code)) {
res.status(401).json({ error: 'Invalid verification code' });
return;
}
const backupCodes = MfaService.generateBackupCodes();
const hashes = await MfaService.hashBackupCodes(backupCodes);
DatabaseService.getInstance().upsertUserMfa(userId, { backup_codes_json: JSON.stringify(hashes) });
console.log('[MFA] Backup codes regenerated:', req.user!.username);
if (isDebugEnabled()) {
console.log('[MFA:diag] backup-codes/regenerate user=', req.user!.username, 'codesIssued=', backupCodes.length);
}
res.json({ backupCodes: backupCodes.map((c) => MfaService.formatBackupCodeForDisplay(c)) });
} catch (error) {
console.error('[MFA] regenerate backup codes error:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Failed to regenerate backup codes' });
}
});
/** Toggle whether SSO logins must also complete the TOTP challenge. */
mfaRouter.put('/mfa/sso-bypass', authMiddleware, (req: Request, res: Response): void => {
try {
const gate = requireEnrolledMfaUser(req, res);
if (!gate) return;
const { userId, mfa } = gate;
const enforce = req.body?.enforce === true;
if ((mfa.sso_enforce_mfa === 1) !== enforce) {
DatabaseService.getInstance().upsertUserMfa(userId, { sso_enforce_mfa: enforce });
console.log('[MFA] SSO bypass toggled:', req.user!.username, 'enforce=', enforce);
}
res.json({ success: true, sso_enforce_mfa: enforce });
} catch (error) {
console.error('[MFA] sso-bypass error:', getErrorMessage(error, 'unknown'));
res.status(500).json({ error: 'Failed to update SSO enforcement' });
}
});
+212
View File
@@ -0,0 +1,212 @@
import { Router, type Request, type Response } from 'express';
import { DatabaseService, type AuthProvider } from '../services/DatabaseService';
import { SSOService } from '../services/SSOService';
import { CryptoService } from '../services/CryptoService';
import { issueSessionCookie, issueMfaPendingCookie } from '../middleware/auth';
import { authRateLimiter, ssoRateLimiter } from '../middleware/rateLimiters';
import { isSecureRequest } from '../helpers/cookies';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
// Seed SSO config from environment variables on module load. One-shot side
// effect at startup; safe to repeat (upsert).
SSOService.getInstance().seedFromEnv();
/** Derive the OAuth callback base URL from SSO_CALLBACK_URL or the request
* Host header, with injection validation. */
function getSSOBaseUrl(req: Request, res: Response): string | null {
const host = req.get('host') || '';
if (!process.env.SSO_CALLBACK_URL && /[\s<>\r\n]/.test(host)) {
console.error('[SSO] Rejected suspicious Host header');
res.redirect('/?sso_error=Invalid+request');
return null;
}
if (!process.env.SSO_CALLBACK_URL && isDebugEnabled()) {
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', host);
}
return process.env.SSO_CALLBACK_URL || `${req.protocol}://${host}`;
}
export const ssoRouter = Router();
ssoRouter.get('/providers', (_req: Request, res: Response): void => {
try {
const providers = SSOService.getInstance().getEnabledProviders();
res.json(providers);
} catch (e) {
console.warn('[SSO] Failed to list enabled providers, returning empty list:', getErrorMessage(e, 'unknown'));
res.json([]);
}
});
ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
}
const result = await SSOService.getInstance().authenticateLDAP(username, password);
if (!result.success || !result.user) {
res.status(401).json({ error: result.error || 'Authentication failed' });
return;
}
const user = SSOService.getInstance().provisionUser({
authProvider: 'ldap',
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
const settings = DatabaseService.getInstance().getGlobalSettings();
// If MFA is enabled AND the user has opted into SSO enforcement, route
// through the TOTP challenge. Otherwise SSO bypasses MFA (default).
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
console.log(`[SSO] LDAP login successful: ${user.username}`);
res.json({ success: true, message: 'Login successful' });
} catch (error) {
const msg = getErrorMessage(error, 'LDAP login failed');
console.error('[SSO] LDAP login error:', msg);
res.status(500).json({ error: msg });
}
});
ssoRouter.get('/oidc/:provider/authorize', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const validProviders = ['oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'];
if (!validProviders.includes(provider)) {
res.status(400).json({ error: 'Invalid SSO provider' });
return;
}
const baseUrl = getSSOBaseUrl(req, res);
if (!baseUrl) return;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const { url, state, codeVerifier } = await SSOService.getInstance().getOIDCAuthorizationUrl(provider, callbackUrl);
// Store state + codeVerifier in an encrypted short-lived cookie.
const cryptoSvc = CryptoService.getInstance();
const statePayload = JSON.stringify({ state, codeVerifier, provider });
res.cookie('sencho_sso_state', cryptoSvc.encrypt(statePayload), {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'lax', // Must be lax for cross-site IdP redirect
maxAge: 5 * 60 * 1000, // 5 minutes
});
res.redirect(url);
} catch (error) {
const msg = getErrorMessage(error, 'SSO initialization failed');
console.error('[SSO] OIDC authorize error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});
ssoRouter.get('/oidc/:provider/callback', ssoRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const provider = String(req.params.provider);
const code = String(req.query.code || '');
const state = String(req.query.state || '');
const oidcError = req.query.error ? String(req.query.error) : '';
const error_description = req.query.error_description ? String(req.query.error_description) : '';
if (oidcError) {
res.redirect(`/?sso_error=${encodeURIComponent(error_description || oidcError)}`);
return;
}
if (!code || !state) {
res.redirect('/?sso_error=Missing+authorization+code');
return;
}
const stateCookie = req.cookies?.sencho_sso_state;
// Always clear the one-time state cookie, regardless of outcome.
res.clearCookie('sencho_sso_state', { httpOnly: true, secure: isSecureRequest(req), sameSite: 'lax' });
if (!stateCookie) {
res.redirect('/?sso_error=SSO+session+expired.+Please+try+again.');
return;
}
const cryptoSvc = CryptoService.getInstance();
let statePayload: { state: string; codeVerifier: string; provider: string };
try {
statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie));
} catch (e) {
console.error('[SSO] Failed to decrypt SSO state cookie:', getErrorMessage(e, 'unknown'));
res.redirect('/?sso_error=Invalid+SSO+session');
return;
}
if (statePayload.provider !== provider) {
res.redirect(`/?sso_error=${encodeURIComponent(`Provider mismatch: expected ${statePayload.provider}, got ${provider}`)}`);
return;
}
const baseUrl = getSSOBaseUrl(req, res);
if (!baseUrl) return;
const callbackUrl = `${baseUrl}/api/auth/sso/oidc/${provider}/callback`;
const result = await SSOService.getInstance().handleOIDCCallback(
provider, callbackUrl,
{ code, state },
statePayload.state,
statePayload.codeVerifier,
);
if (!result.success || !result.user) {
res.redirect(`/?sso_error=${encodeURIComponent(result.error || 'Authentication failed')}`);
return;
}
const user = SSOService.getInstance().provisionUser({
authProvider: provider as AuthProvider,
providerId: result.user.providerId,
preferredUsername: result.user.preferredUsername,
email: result.user.email,
role: result.user.role,
});
const settings = DatabaseService.getInstance().getGlobalSettings();
// If MFA is enabled AND the user has opted into SSO enforcement, set only
// the partial-auth cookie. The frontend surfaces the challenge screen
// based on `/api/auth/status` after the redirect lands.
const mfa = DatabaseService.getInstance().getUserMfa(user.id);
if (isDebugEnabled()) {
console.log('[MFA:diag] login: path=oidc provider=', provider, 'user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
console.log(`[SSO] OIDC login password OK, MFA challenge pending: ${user.username} via ${provider}`);
res.redirect('/');
return;
}
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
console.log(`[SSO] OIDC login successful: ${user.username} via ${provider}`);
res.redirect('/');
} catch (error) {
const msg = getErrorMessage(error, 'SSO callback failed');
console.error('[SSO] OIDC callback error:', msg);
res.redirect(`/?sso_error=${encodeURIComponent(msg)}`);
}
});