From b329916a0c25fdb892611599b2bf1f43054dbf52 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 23 Apr 2026 20:43:32 -0400 Subject: [PATCH] refactor(backend): extract auth/MFA/SSO routers from index.ts (phase 4a-2) (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/helpers/constants.ts | 8 + backend/src/index.ts | 871 +------------------------------ backend/src/middleware/auth.ts | 16 + backend/src/routes/auth.ts | 231 ++++++++ backend/src/routes/mfa.ts | 405 ++++++++++++++ backend/src/routes/sso.ts | 212 ++++++++ 6 files changed, 885 insertions(+), 858 deletions(-) create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/mfa.ts create mode 100644 backend/src/routes/sso.ts diff --git a/backend/src/helpers/constants.ts b/backend/src/helpers/constants.ts index 1c8f18e4..b024eaaa 100644 --- a/backend/src/helpers/constants.ts +++ b/backend/src/helpers/constants.ts @@ -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. diff --git a/backend/src/index.ts b/backend/src/index.ts index b6a1fa57..ccf26c9d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index b9bc696f..437eefe4 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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); + } +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 00000000..f3bf5c97 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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') }); + } +}); diff --git a/backend/src/routes/mfa.ts b/backend/src/routes/mfa.ts new file mode 100644 index 00000000..0feb265b --- /dev/null +++ b/backend/src/routes/mfa.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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' }); + } +}); diff --git a/backend/src/routes/sso.ts b/backend/src/routes/sso.ts new file mode 100644 index 00000000..0f1e7691 --- /dev/null +++ b/backend/src/routes/sso.ts @@ -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 => { + 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 => { + 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 => { + 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)}`); + } +});