/** * OAuth / OpenID Connect routes for Redlight. * * Flow: * 1. Client calls GET /api/oauth/providers → returns enabled provider info * 2. Client calls GET /api/oauth/authorize → server generates PKCE + state, redirects to IdP * 3. IdP redirects to GET /api/oauth/callback with code + state * 4. Server exchanges code for tokens, fetches user info, creates/links account, returns JWT * * Security: * - PKCE (S256) everywhere * - Cryptographic state token (single-use, 10-min TTL) * - Client secret never leaves the server * - OAuth user info validated and sanitized before DB insertion * - Rate limited callback endpoint */ import { Router } from 'express'; import { rateLimit } from 'express-rate-limit'; import { v4 as uuidv4 } from 'uuid'; import { getDb } from '../config/database.js'; import { generateToken, getBaseUrl } from '../middleware/auth.js'; import { log } from '../config/logger.js'; import redis from '../config/redis.js'; import { getOAuthConfig, discoverOIDC, createOAuthState, consumeOAuthState, generateCodeVerifier, computeCodeChallenge, exchangeCode, fetchUserInfo, } from '../config/oauth.js'; const router = Router(); // Rate limit the callback to prevent brute-force of authorization codes const callbackLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many OAuth attempts. Please try again later.' }, }); // ── GET /api/oauth/providers — List available OAuth providers (public) ─────── router.get('/providers', async (req, res) => { try { const config = await getOAuthConfig(); if (!config) { return res.json({ providers: [] }); } // Never expose client secret or issuer details to the client res.json({ providers: [ { id: 'oidc', name: config.displayName || 'SSO', }, ], }); } catch (err) { log.auth.error(`OAuth providers error: ${err.message}`); res.json({ providers: [] }); } }); // ── GET /api/oauth/authorize — Start OAuth flow (redirects to IdP) ────────── router.get('/authorize', async (req, res) => { try { const config = await getOAuthConfig(); if (!config) { return res.status(400).json({ error: 'OAuth is not configured' }); } // Discover OIDC endpoints const oidc = await discoverOIDC(config.issuer); // Generate PKCE pair const codeVerifier = generateCodeVerifier(); const codeChallenge = computeCodeChallenge(codeVerifier); // Optional return_to from query (validate it's a relative path) let returnTo = req.query.return_to || null; if (returnTo && (typeof returnTo !== 'string' || !returnTo.startsWith('/') || returnTo.startsWith('//'))) { returnTo = null; // prevent open redirect } // Create server-side state const state = await createOAuthState('oidc', codeVerifier, returnTo); // Build callback URL const baseUrl = getBaseUrl(req); const redirectUri = `${baseUrl}/api/oauth/callback`; // Build authorization URL const params = new URLSearchParams({ response_type: 'code', client_id: config.clientId, redirect_uri: redirectUri, scope: 'openid email profile', state, code_challenge: codeChallenge, code_challenge_method: 'S256', }); const authUrl = `${oidc.authorization_endpoint}?${params.toString()}`; res.redirect(authUrl); } catch (err) { log.auth.error(`OAuth authorize error: ${err.message}`); res.status(500).json({ error: 'Could not start OAuth flow' }); } }); // ── GET /api/oauth/callback — Handle IdP callback ────────────────────────── router.get('/callback', callbackLimiter, async (req, res) => { try { const { code, state, error: oauthError, error_description } = req.query; // Build frontend error redirect helper const baseUrl = getBaseUrl(req); const errorRedirect = (msg) => res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent(msg)}`); // Handle IdP errors if (oauthError) { log.auth.warn(`OAuth IdP error: ${oauthError} – ${error_description || ''}`); return errorRedirect(error_description || oauthError); } if (!code || !state) { return errorRedirect('Missing authorization code or state'); } // Validate and consume state (CSRF + PKCE verifier retrieval) const stateData = await consumeOAuthState(state); if (!stateData) { return errorRedirect('Invalid or expired OAuth state. Please try again.'); } // Load provider config const config = await getOAuthConfig(); if (!config) { return errorRedirect('OAuth is not configured'); } // Discover OIDC endpoints const oidc = await discoverOIDC(config.issuer); // Exchange code for tokens const redirectUri = `${baseUrl}/api/oauth/callback`; const tokenResponse = await exchangeCode( oidc, code, redirectUri, config.clientId, config.clientSecret, stateData.code_verifier, ); if (!tokenResponse.access_token) { return errorRedirect('Token exchange failed: no access token received'); } // Fetch user info from the IdP let userInfo; if (oidc.userinfo_endpoint) { userInfo = await fetchUserInfo(oidc.userinfo_endpoint, tokenResponse.access_token); } else { return errorRedirect('Provider does not support userinfo endpoint'); } // Extract and validate user attributes const email = (userInfo.email || '').toLowerCase().trim(); const sub = userInfo.sub || ''; const name = userInfo.preferred_username || userInfo.name || email.split('@')[0] || ''; const displayName = userInfo.name || userInfo.preferred_username || ''; if (!email || !sub) { return errorRedirect('OAuth provider did not return an email or subject'); } // Basic email validation const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/; if (!EMAIL_RE.test(email)) { return errorRedirect('OAuth provider returned an invalid email address'); } const db = getDb(); // ── Link or create user ────────────────────────────────────────────── // 1. Check if there's already a user linked with this OAuth provider + sub let user = await db.get( 'SELECT id, name, display_name, email, role, email_verified FROM users WHERE oauth_provider = ? AND oauth_provider_id = ?', ['oidc', sub], ); if (!user) { // 2. Check if a user with this email already exists (link accounts) user = await db.get( 'SELECT id, name, display_name, email, role, email_verified, oauth_provider FROM users WHERE email = ?', [email], ); if (user) { // Link OAuth to existing account await db.run( 'UPDATE users SET oauth_provider = ?, oauth_provider_id = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', ['oidc', sub, user.id], ); log.auth.info(`Linked OAuth (oidc/${sub}) to existing user ${user.email}`); } else { // 3. Auto-register new user (if enabled) if (!config.autoRegister) { return errorRedirect('No account found for this email. Please register first or ask an admin.'); } // Check registration mode const regModeSetting = await db.get("SELECT value FROM settings WHERE key = 'registration_mode'"); const registrationMode = regModeSetting?.value || 'open'; if (registrationMode === 'invite') { return errorRedirect('Registration is invite-only. An administrator must create your account first.'); } // Sanitize username: only allow safe characters const safeUsername = name.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 30) || `user_${Date.now()}`; // Ensure username is unique let finalUsername = safeUsername; const existingUsername = await db.get('SELECT id FROM users WHERE LOWER(name) = LOWER(?)', [safeUsername]); if (existingUsername) { finalUsername = `${safeUsername}_${Date.now().toString(36)}`; } // No password needed for OAuth users — use a random hash that can't be guessed const randomPasswordHash = `oauth:${uuidv4()}`; const result = await db.run( `INSERT INTO users (name, display_name, email, password_hash, email_verified, oauth_provider, oauth_provider_id) VALUES (?, ?, ?, ?, 1, ?, ?)`, [finalUsername, displayName.slice(0, 100) || finalUsername, email, randomPasswordHash, 'oidc', sub], ); user = await db.get( 'SELECT id, name, display_name, email, role, email_verified FROM users WHERE id = ?', [result.lastInsertRowid], ); log.auth.info(`Created new OAuth user: ${email} (oidc/${sub})`); } } // Generate JWT const token = generateToken(user.id); // Store id_token in Redis for RP-Initiated Logout (Keycloak SLO) if (tokenResponse.id_token) { try { await redis.setex(`oidc:id_token:${user.id}`, 7 * 24 * 3600, tokenResponse.id_token); } catch (redisErr) { log.auth.warn(`Failed to cache OIDC id_token: ${redisErr.message}`); } } // Redirect to frontend callback page with token. // Use a hash fragment so the token is never sent to the server (not logged, not in Referer headers). const returnTo = stateData.return_to || '/dashboard'; res.redirect(`${baseUrl}/oauth/callback#token=${encodeURIComponent(token)}&return_to=${encodeURIComponent(returnTo)}`); } catch (err) { log.auth.error(`OAuth callback error: ${err.message}`); const baseUrl = getBaseUrl(req); res.redirect(`${baseUrl}/oauth/callback?error=${encodeURIComponent('OAuth authentication failed. Please try again.')}`); } }); export default router;