'use strict'; const crypto = require('crypto'); const express = require('express'); const Database = require('../storage/database'); /** * OpenID Connect (authorization-code + PKCE) login for the web dashboard, * modeled on theta42/proxy's auth flow. * * - Config lives in the settings manager under auth.* (SSO endpoints, client * credentials, allowed users/groups). auth.enabled=false (default) leaves * the dashboard open exactly as before. * - Sessions are opaque random tokens in the storage sqlite DB, delivered as * an HttpOnly SameSite=Lax cookie so the existing dashboard fetch() calls * work unchanged. API clients may instead send the token in an * `auth-token` header. * - The in-flight OIDC state (PKCE verifier + post-login redirect) is held * in memory with a 5-minute TTL — single process, no cleanup job needed. * * Identity is read from the SSO's userinfo endpoint server-side; ID-token * signatures are not verified (same trade-off as the reference impl). */ const COOKIE_NAME = 'mcbt_session'; const STATE_TTL_MS = 5 * 60 * 1000; // ======================================== // Config // ======================================== function authConf() { const settings = require('../settings/manager'); return { enabled: settings.get('auth.enabled') === true, authorizationEndpoint: settings.get('auth.authorizationEndpoint'), tokenEndpoint: settings.get('auth.tokenEndpoint'), userinfoEndpoint: settings.get('auth.userinfoEndpoint'), clientId: settings.get('auth.clientId'), clientSecret: settings.get('auth.clientSecret'), redirectUri: settings.get('auth.redirectUri'), scopes: settings.get('auth.scopes') || ['openid', 'profile', 'email', 'groups'], usernameClaim: settings.get('auth.usernameClaim') || 'preferred_username', groupsClaim: settings.get('auth.groupsClaim') || 'groups', allowedUsers: settings.get('auth.allowedUsers') || [], allowedGroups: settings.get('auth.allowedGroups') || [], tokenTTL: settings.get('auth.tokenTTL') || 30 * 24 * 3600, // seconds }; } // ======================================== // Small helpers (ported from theta42/proxy) // ======================================== const base64url = buf => buf.toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); function randomToken(bytes = 32) { return base64url(crypto.randomBytes(bytes)); } function codeChallengeS256(verifier) { return base64url(crypto.createHash('sha256').update(verifier).digest()); } /** * Constrain a post-login redirect target to a same-origin path. * Rejects absolute URLs, protocol-relative ("//evil.com"), and scheme * targets ("javascript:..."). Anything not a plain "/path" becomes "/". */ function safeInternalPath(path) { if (typeof path !== 'string' || path.charAt(0) !== '/' || path.charAt(1) === '/' || path.charAt(1) === '\\') { return '/'; } return path; } /** Minimal per-IP fixed-window rate limiter (no external dependency). */ function rateLimiter(max = 60, windowMs = 15 * 60 * 1000) { const hits = new Map(); return (req, res, next) => { const now = Date.now(); const ip = req.ip || req.socket.remoteAddress || 'unknown'; let rec = hits.get(ip); if (!rec || now > rec.reset) { rec = { count: 0, reset: now + windowMs }; hits.set(ip, rec); } if (++rec.count > max) { return res.status(429).json({ error: 'Too many attempts, please try again later.' }); } if (hits.size > 1000) { for (const [k, v] of hits) if (now > v.reset) hits.delete(k); } next(); }; } function parseCookies(req) { const header = req.headers.cookie; if (!header) return {}; const out = {}; for (const part of header.split(';')) { const idx = part.indexOf('='); if (idx === -1) continue; out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()); } return out; } function isSecureRequest(req) { return req.secure || req.headers['x-forwarded-proto'] === 'https'; } // ======================================== // OIDC client // ======================================== function createAuthRequest() { const state = randomToken(32); const codeVerifier = randomToken(32); return { state, codeVerifier, codeChallenge: codeChallengeS256(codeVerifier) }; } function buildAuthUrl(state, codeChallenge) { const o = authConf(); const params = new URLSearchParams({ response_type: 'code', client_id: o.clientId, redirect_uri: o.redirectUri, scope: o.scopes.join(' '), state, code_challenge: codeChallenge, code_challenge_method: 'S256', }); return `${o.authorizationEndpoint}?${params.toString()}`; } async function exchangeCode(code, codeVerifier) { const o = authConf(); const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: o.redirectUri, client_id: o.clientId, client_secret: o.clientSecret, code_verifier: codeVerifier, }); const res = await fetch(o.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, body: body.toString(), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Token exchange failed (${res.status}): ${text.slice(0, 200)}`); } return res.json(); } async function fetchUserInfo(accessToken) { const o = authConf(); const res = await fetch(o.userinfoEndpoint, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json', }, }); if (!res.ok) throw new Error(`Userinfo request failed (${res.status})`); return res.json(); } function claimsToIdentity(claims) { const o = authConf(); const username = claims[o.usernameClaim] || claims.sub; let groups = claims[o.groupsClaim] || []; if (!Array.isArray(groups)) groups = [groups].filter(Boolean); return { username, groups }; } /** allowedUsers / allowedGroups gate — both empty means any SSO user. */ function identityAllowed(identity) { const o = authConf(); const users = (o.allowedUsers || []).map(u => String(u).toLowerCase()); const groups = (o.allowedGroups || []).map(g => String(g).toLowerCase()); if (users.length === 0 && groups.length === 0) return true; if (users.includes(String(identity.username).toLowerCase())) return true; return identity.groups.some(g => groups.includes(String(g).toLowerCase())); } // ======================================== // One-time OIDC state store (in-memory, TTL) // ======================================== const _states = new Map(); // state -> { codeVerifier, redirect, expires } function saveState(state, data) { _states.set(state, { ...data, expires: Date.now() + STATE_TTL_MS }); // Opportunistic sweep of expired/abandoned logins for (const [k, v] of _states) if (Date.now() > v.expires) _states.delete(k); } /** Consume a state record — one-time use bounds replay. */ function takeState(state) { const rec = _states.get(state); if (!rec) return null; _states.delete(state); if (Date.now() > rec.expires) return null; return rec; } // ======================================== // Session token store (sqlite) // ======================================== let _tableReady = false; async function ensureTable() { if (_tableReady) return; await Database.db.run(` CREATE TABLE IF NOT EXISTS auth_tokens ( token TEXT PRIMARY KEY, username TEXT NOT NULL, groups TEXT DEFAULT '[]', created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ) `); _tableReady = true; } async function createSession(identity) { await ensureTable(); const token = randomToken(32); const now = Date.now(); await Database.db.run( 'INSERT INTO auth_tokens (token, username, groups, created_at, expires_at) VALUES (?, ?, ?, ?, ?)', [token, identity.username, JSON.stringify(identity.groups || []), now, now + authConf().tokenTTL * 1000] ); // Opportunistic cleanup of expired sessions await Database.db.run('DELETE FROM auth_tokens WHERE expires_at < ?', [now]); return token; } async function checkSession(token) { if (!token) return null; await ensureTable(); const row = await Database.db.get('SELECT * FROM auth_tokens WHERE token = ?', [token]); if (!row) return null; if (row.expires_at < Date.now()) { await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]); return null; } return { username: row.username, groups: JSON.parse(row.groups || '[]') }; } async function destroySession(token) { if (!token) return; await ensureTable(); await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]); } // ======================================== // Middleware // ======================================== function readToken(req) { return parseCookies(req)[COOKIE_NAME] || req.header('auth-token') || null; } /** * Gate every route behind a session when auth.enabled. Browsers get a * redirect to the login page; API callers get a 401. */ async function middleware(req, res, next) { try { if (!authConf().enabled) return next(); if (req.path === '/health' || req.path === '/auth' || req.path.startsWith('/auth/')) return next(); const session = await checkSession(readToken(req)); if (session) { req.user = session.username; req.groups = session.groups; return next(); } if (req.method === 'GET' && req.accepts(['json', 'html']) === 'html') { return res.redirect('/auth/login?redirect=' + encodeURIComponent(safeInternalPath(req.originalUrl))); } return res.status(401).json({ error: 'Authentication required' }); } catch (error) { next(error); } } // ======================================== // Router // ======================================== function createRouter() { const router = express.Router(); const limiter = rateLimiter(60, 15 * 60 * 1000); router.get('/login', (req, res) => { const redirect = safeInternalPath(req.query.redirect || '/'); const error = req.query.error ? String(req.query.error).slice(0, 200) : null; res.send(loginPageHTML(redirect, error)); }); // OIDC login start: create a PKCE + state challenge, stash it, redirect // the browser to the SSO authorize endpoint. router.get('/oidc/start', limiter, (req, res) => { const o = authConf(); if (!o.enabled) return res.status(404).json({ error: 'Auth is not enabled' }); if (!o.authorizationEndpoint || !o.clientId) { return res.status(500).json({ error: 'OIDC is not configured (auth.authorizationEndpoint / auth.clientId)' }); } const { state, codeVerifier, codeChallenge } = createAuthRequest(); saveState(state, { codeVerifier, // Sanitize now so a hostile ?redirect= can't be stored and later // reflected into navigation. redirect: safeInternalPath(req.query.redirect || '/'), }); res.redirect(buildAuthUrl(state, codeChallenge)); }); // OIDC callback: validate + consume state, exchange the code, read // identity from userinfo, set the session cookie, redirect into the app. router.get('/oidc/callback', limiter, async (req, res) => { try { const { code, state } = req.query; if (!code || !state) throw new Error('Missing code or state'); const saved = takeState(String(state)); if (!saved) throw new Error('Unknown or expired login attempt — try again'); const tokens = await exchangeCode(String(code), saved.codeVerifier); const claims = await fetchUserInfo(tokens.access_token); const identity = claimsToIdentity(claims); if (!identityAllowed(identity)) { console.log(`Auth: DENIED login for '${identity.username}' (groups: ${identity.groups.join(', ') || 'none'})`); return res.redirect('/auth/login?error=' + encodeURIComponent(`Account '${identity.username}' is not authorized for this dashboard.`)); } const token = await createSession(identity); console.log(`Auth: '${identity.username}' logged in`); const flags = [ `${COOKIE_NAME}=${encodeURIComponent(token)}`, 'HttpOnly', 'Path=/', 'SameSite=Lax', `Max-Age=${authConf().tokenTTL}`, ]; if (isSecureRequest(req)) flags.push('Secure'); res.setHeader('Set-Cookie', flags.join('; ')); res.redirect(safeInternalPath(saved.redirect || '/')); } catch (error) { console.error('Auth: OIDC callback error:', error.message); res.redirect('/auth/login?error=' + encodeURIComponent(error.message)); } }); router.all('/logout', async (req, res) => { try { await destroySession(readToken(req)); } catch (error) { console.error('Auth: logout error:', error.message); } res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`); if (req.accepts(['json', 'html']) === 'html') return res.redirect('/auth/login'); res.json({ message: 'Bye' }); }); // Who am I — lets the UI show the logged-in user router.get('/me', async (req, res) => { if (!authConf().enabled) return res.json({ enabled: false }); const session = await checkSession(readToken(req)); if (!session) return res.status(401).json({ enabled: true, error: 'Not logged in' }); res.json({ enabled: true, username: session.username, groups: session.groups }); }); return router; } function escapeHtml(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function loginPageHTML(redirect, error) { return `