/** * OAuth / OpenID Connect configuration for Redlight. * * Supports generic OIDC providers (Keycloak, Authentik, Google, GitHub, etc.) * configured at runtime via admin settings stored in the database. * * Security: * - PKCE (S256) on every authorization request * - Anti-CSRF via cryptographic `state` parameter stored server-side * - State entries expire after 10 minutes and are single-use * - Client secrets are stored AES-256-GCM encrypted in the DB * - Only https callback URLs in production * - Token exchange uses server-side secret, never exposed to the browser */ import crypto from 'crypto'; import { getDb } from './database.js'; import { log } from './logger.js'; // ── Encryption helpers for client secrets ────────────────────────────────── // Derive a key from JWT_SECRET (always available) const ENCRYPTION_KEY = crypto .createHash('sha256') .update(process.env.JWT_SECRET || '') .digest(); // 32 bytes → AES-256 /** * Encrypt a plaintext string with AES-256-GCM. * Returns "iv:authTag:ciphertext" (all hex-encoded). */ export function encryptSecret(plaintext) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); let encrypted = cipher.update(plaintext, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag().toString('hex'); return `${iv.toString('hex')}:${authTag}:${encrypted}`; } /** * Decrypt an AES-256-GCM encrypted string. */ export function decryptSecret(encryptedStr) { const [ivHex, authTagHex, ciphertext] = encryptedStr.split(':'); const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } // ── PKCE helpers ─────────────────────────────────────────────────────────── /** Generate a cryptographically random code_verifier (RFC 7636). */ export function generateCodeVerifier() { return crypto.randomBytes(32).toString('base64url'); } /** Compute the S256 code_challenge from a code_verifier. */ export function computeCodeChallenge(verifier) { return crypto.createHash('sha256').update(verifier).digest('base64url'); } // ── State management (anti-CSRF) ─────────────────────────────────────────── const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes /** * Create and persist an OAuth state token with associated PKCE verifier. * @param {string} provider – provider key (e.g. 'oidc') * @param {string} codeVerifier – PKCE code_verifier * @param {string|null} returnTo – optional return URL after login * @returns {Promise} state token */ export async function createOAuthState(provider, codeVerifier, returnTo = null) { const state = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString(); const db = getDb(); await db.run( 'INSERT INTO oauth_states (state, provider, code_verifier, return_to, expires_at) VALUES (?, ?, ?, ?, ?)', [state, provider, codeVerifier, returnTo, expiresAt], ); return state; } /** * Consume (validate + delete) an OAuth state token. * Returns the stored data or null if invalid/expired. * @param {string} state * @returns {Promise<{ provider: string, code_verifier: string, return_to: string|null } | null>} */ export async function consumeOAuthState(state) { if (!state || typeof state !== 'string' || state.length > 128) return null; const db = getDb(); const row = await db.get( 'SELECT * FROM oauth_states WHERE state = ?', [state], ); if (!row) return null; // Always delete (single-use) await db.run('DELETE FROM oauth_states WHERE state = ?', [state]); // Check expiry if (new Date(row.expires_at) < new Date()) return null; return { provider: row.provider, code_verifier: row.code_verifier, return_to: row.return_to, }; } /** * Garbage-collect expired OAuth states (called periodically). */ export async function cleanupExpiredStates() { try { const db = getDb(); await db.run('DELETE FROM oauth_states WHERE expires_at < CURRENT_TIMESTAMP'); } catch (err) { log.auth.warn(`OAuth state cleanup failed: ${err.message}`); } } // ── Provider configuration ───────────────────────────────────────────────── /** * Load the stored OAuth provider config from the settings table. * Returns null if OAuth is not configured. * @returns {Promise<{ issuer: string, clientId: string, clientSecret: string, displayName: string, autoRegister: boolean } | null>} */ export async function getOAuthConfig() { try { const db = getDb(); const row = await db.get("SELECT value FROM settings WHERE key = 'oauth_config'"); if (!row?.value) return null; const config = JSON.parse(row.value); if (!config.issuer || !config.clientId || !config.encryptedSecret) return null; return { issuer: config.issuer, clientId: config.clientId, clientSecret: decryptSecret(config.encryptedSecret), displayName: config.displayName || 'SSO', autoRegister: config.autoRegister !== false, }; } catch (err) { log.auth.error(`Failed to load OAuth config: ${err.message}`); return null; } } /** * Save OAuth provider config to the settings table. * The client secret is encrypted before storage. */ export async function saveOAuthConfig({ issuer, clientId, clientSecret, displayName, autoRegister }) { const db = getDb(); const config = { issuer, clientId, encryptedSecret: encryptSecret(clientSecret), displayName: displayName || 'SSO', autoRegister: autoRegister !== false, }; const value = JSON.stringify(config); const existing = await db.get("SELECT key FROM settings WHERE key = 'oauth_config'"); if (existing) { await db.run("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = 'oauth_config'", [value]); } else { await db.run("INSERT INTO settings (key, value) VALUES ('oauth_config', ?) RETURNING key", [value]); } } /** * Remove OAuth configuration. */ export async function deleteOAuthConfig() { const db = getDb(); await db.run("DELETE FROM settings WHERE key = 'oauth_config'"); } // ── OIDC Discovery ───────────────────────────────────────────────────────── // Cache discovered OIDC endpoints { authorization_endpoint, token_endpoint, userinfo_endpoint, ... } const discoveryCache = new Map(); const DISCOVERY_TTL_MS = 30 * 60 * 1000; // 30 minutes /** * Fetch and cache the OpenID Connect discovery document for the given issuer. * @param {string} issuer * @returns {Promise} */ export async function discoverOIDC(issuer) { const cached = discoveryCache.get(issuer); if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL_MS) { return cached.data; } // Normalize issuer URL const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer; const url = `${base}/.well-known/openid-configuration`; const response = await fetch(url, { signal: AbortSignal.timeout(10_000) }); if (!response.ok) { throw new Error(`OIDC discovery failed for ${issuer}: HTTP ${response.status}`); } const data = await response.json(); if (!data.authorization_endpoint || !data.token_endpoint) { throw new Error(`OIDC discovery response missing required endpoints`); } discoveryCache.set(issuer, { data, fetchedAt: Date.now() }); return data; } /** * Exchange an authorization code for tokens. * @param {object} oidcConfig – discovery document * @param {string} code * @param {string} redirectUri * @param {string} clientId * @param {string} clientSecret * @param {string} codeVerifier – PKCE verifier * @returns {Promise} token response */ export async function exchangeCode(oidcConfig, code, redirectUri, clientId, clientSecret, codeVerifier) { const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier, }); const response = await fetch(oidcConfig.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: AbortSignal.timeout(15_000), }); if (!response.ok) { const errText = await response.text().catch(() => ''); throw new Error(`Token exchange failed: HTTP ${response.status} – ${errText.slice(0, 200)}`); } return response.json(); } /** * Fetch user info from the provider's userinfo endpoint. * @param {string} userInfoUrl * @param {string} accessToken * @returns {Promise} */ export async function fetchUserInfo(userInfoUrl, accessToken) { const response = await fetch(userInfoUrl, { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(10_000), }); if (!response.ok) { throw new Error(`UserInfo fetch failed: HTTP ${response.status}`); } return response.json(); }