const express = require('express'); const crypto = require('crypto'); const fetch = require('node-fetch'); const jwt = require('jsonwebtoken'); const { db } = require('../db/database'); const { JWT_SECRET } = require('../config'); const router = express.Router(); // In-memory state store for CSRF protection (state → { createdAt, redirectUri }) const pendingStates = new Map(); const STATE_TTL = 5 * 60 * 1000; // 5 minutes // Cleanup expired states periodically setInterval(() => { const now = Date.now(); for (const [state, data] of pendingStates) { if (now - data.createdAt > STATE_TTL) pendingStates.delete(state); } }, 60 * 1000); // Read OIDC config from app_settings function getOidcConfig() { const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || null; const issuer = get('oidc_issuer'); const clientId = get('oidc_client_id'); const clientSecret = get('oidc_client_secret'); const displayName = get('oidc_display_name') || 'SSO'; if (!issuer || !clientId || !clientSecret) return null; return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName }; } // Cache discovery document let discoveryCache = null; let discoveryCacheTime = 0; const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour async function discover(issuer) { if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) { return discoveryCache; } const res = await fetch(`${issuer}/.well-known/openid-configuration`); if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); const doc = await res.json(); doc._issuer = issuer; discoveryCache = doc; discoveryCacheTime = Date.now(); return doc; } function generateToken(user) { return jwt.sign( { id: user.id, username: user.username, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '24h' } ); } function frontendUrl(path) { const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173'; return base + path; } // GET /api/auth/oidc/login — redirect to OIDC provider router.get('/login', async (req, res) => { const config = getOidcConfig(); if (!config) return res.status(400).json({ error: 'OIDC not configured' }); try { const doc = await discover(config.issuer); const state = crypto.randomBytes(32).toString('hex'); const proto = req.headers['x-forwarded-proto'] || req.protocol; const host = req.headers['x-forwarded-host'] || req.headers.host; const redirectUri = `${proto}://${host}/api/auth/oidc/callback`; pendingStates.set(state, { createdAt: Date.now(), redirectUri }); const params = new URLSearchParams({ response_type: 'code', client_id: config.clientId, redirect_uri: redirectUri, scope: 'openid email profile', state, }); res.redirect(`${doc.authorization_endpoint}?${params}`); } catch (err) { console.error('[OIDC] Login error:', err.message); res.status(500).json({ error: 'OIDC login failed' }); } }); // GET /api/auth/oidc/callback — handle provider callback router.get('/callback', async (req, res) => { const { code, state, error: oidcError } = req.query; if (oidcError) { console.error('[OIDC] Provider error:', oidcError); return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError))); } if (!code || !state) { return res.redirect(frontendUrl('/login?oidc_error=missing_params')); } const pending = pendingStates.get(state); if (!pending) { return res.redirect(frontendUrl('/login?oidc_error=invalid_state')); } pendingStates.delete(state); const config = getOidcConfig(); if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured')); try { const doc = await discover(config.issuer); // Exchange code for tokens const tokenRes = await fetch(doc.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: pending.redirectUri, client_id: config.clientId, client_secret: config.clientSecret, }), }); const tokenData = await tokenRes.json(); if (!tokenRes.ok || !tokenData.access_token) { console.error('[OIDC] Token exchange failed:', tokenData); return res.redirect(frontendUrl('/login?oidc_error=token_failed')); } // Get user info const userInfoRes = await fetch(doc.userinfo_endpoint, { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); const userInfo = await userInfoRes.json(); if (!userInfo.email) { return res.redirect(frontendUrl('/login?oidc_error=no_email')); } const email = userInfo.email.toLowerCase(); const name = userInfo.name || userInfo.preferred_username || email.split('@')[0]; const sub = userInfo.sub; // Find existing user by OIDC sub or email let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer); if (!user) { user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email); } if (user) { // Existing user — link OIDC if not already linked if (!user.oidc_sub) { db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id); } } else { // New user — check if registration is allowed const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const isFirstUser = userCount === 0; if (!isFirstUser) { const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get(); if (setting?.value === 'false') { return res.redirect(frontendUrl('/login?oidc_error=registration_disabled')); } } // Create user (first user = admin) const role = isFirstUser ? 'admin' : 'user'; // Generate a random password hash (user won't use password login) const randomPass = crypto.randomBytes(32).toString('hex'); const bcrypt = require('bcryptjs'); const hash = bcrypt.hashSync(randomPass, 10); // Ensure unique username let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user'; const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username); if (existing) username = `${username}_${Date.now() % 10000}`; const result = db.prepare( 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)' ).run(username, email, hash, role, sub, config.issuer); user = { id: Number(result.lastInsertRowid), username, email, role }; } // Update last login db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); // Generate JWT and redirect to frontend const token = generateToken(user); // In dev mode, frontend runs on a different port res.redirect(frontendUrl(`/login?token=${token}`)); } catch (err) { console.error('[OIDC] Callback error:', err); res.redirect(frontendUrl('/login?oidc_error=server_error')); } }); module.exports = router;