'use strict'; /** * OIDC / authentik integration service. * * Uses openid-client@5 for full ID token validation including JWKS-backed * signature verification, issuer, audience, expiry, and nonce checks. * * Disabled by default. OIDC settings are resolved from the database first, * then environment variables when DB values are blank, then safe defaults. * Local username/password login continues to work regardless of this setting. * * ── authentik setup notes ──────────────────────────────────────────────────── * * 1. In authentik, create an OAuth2/OpenID Provider: * - Name: bill-tracker (or any) * - Client type: Confidential * - Authorization flow: explicit / implicit as appropriate * - Redirect URI: must exactly match the Admin panel Redirect URI * (e.g. https://bills.example.com/api/auth/oidc/callback) * * 2. Scopes: openid, email, profile, groups * (groups scope is needed for group → role mapping) * * 3. Set the group claim: authentik sends groups as a flat list in the * "groups" claim. Set the Admin panel admin group to the authentik group * name that should receive the admin role in bill-tracker. * * 4. Configure OIDC in Admin → Authentication Methods: * Issuer URL, Client ID, Client Secret, Redirect URI, Scopes, * Auto-provision, and Admin group. * * 5. Optional bootstrap fallback environment variables: * OIDC_ENABLED=true * OIDC_ISSUER_URL=https://auth.example.com/application/o/bill-tracker/ * OIDC_CLIENT_ID= * OIDC_CLIENT_SECRET= * OIDC_REDIRECT_URI=https://bills.example.com/api/auth/oidc/callback * OIDC_SCOPES="openid email profile groups" * OIDC_ADMIN_GROUP=bill-tracker-admins * OIDC_DEFAULT_ROLE=user * OIDC_AUTO_PROVISION=true * OIDC_PROVIDER_NAME=authentik * * DB settings take precedence. Env values are used only while the * corresponding DB value is blank. * * 6. Keep local login enabled until authentik login is tested and working. */ const crypto = require('crypto'); const { Issuer } = require('openid-client'); const { getDb, getSetting } = require('../db/database'); // ── Configuration ───────────────────────────────────────────────────────────── function trimOrNull(value) { if (value === undefined || value === null) return null; const s = String(value).trim(); return s || null; } function settingOrEnv(settingKey, envKey, fallback = null) { return trimOrNull(getSetting(settingKey)) || trimOrNull(process.env[envKey]) || fallback; } function boolSettingOrEnv(settingKey, envKey, fallback) { const dbValue = trimOrNull(getSetting(settingKey)); if (dbValue !== null) return dbValue === 'true'; const envValue = trimOrNull(process.env[envKey]); if (envValue !== null) return envValue === 'true'; return fallback; } function normalizeScopes(value) { const scopes = String(value || 'openid email profile groups').split(/\s+/).filter(Boolean); return scopes.length ? scopes : ['openid', 'email', 'profile', 'groups']; } function normalizeTokenAuthMethod(value) { return value === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic'; } /** * Returns the effective OIDC config, or null if required settings are missing. * DB settings win; env vars are optional fallback/bootstrap defaults. * Never returns client_secret in a form intended for clients. */ function getOidcConfig() { const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL'); const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID'); const clientSecret = settingOrEnv('oidc_client_secret', 'OIDC_CLIENT_SECRET'); const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI'); const tokenAuthMethod = settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic'); if (!issuerUrl || !clientId || !clientSecret || !redirectUri) { return null; } return { enabled: true, issuerUrl, clientId, clientSecret, // server-side only; never returned to clients or logged tokenEndpointAuthMethod: normalizeTokenAuthMethod(tokenAuthMethod), redirectUri, scopes: normalizeScopes(settingOrEnv('oidc_scopes', 'OIDC_SCOPES', 'openid email profile groups')), adminGroup: settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP'), defaultRole: 'user', autoProvision: boolSettingOrEnv('oidc_auto_provision', 'OIDC_AUTO_PROVISION', true), providerName: settingOrEnv('oidc_provider_name', 'OIDC_PROVIDER_NAME', 'authentik'), }; } function getOidcConfigStatus() { const issuerUrl = settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL'); const clientId = settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID'); const clientSecret = settingOrEnv('oidc_client_secret', 'OIDC_CLIENT_SECRET'); const redirectUri = settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI'); const missing = []; if (!issuerUrl) missing.push('issuer URL'); if (!clientId) missing.push('client ID'); if (!clientSecret) missing.push('client secret'); if (!redirectUri) missing.push('redirect URI'); return { oidc_configured: !!(issuerUrl && clientId && clientSecret && redirectUri), oidc_issuer_url_set: !!issuerUrl, oidc_client_id_set: !!clientId, oidc_client_secret_set: !!clientSecret, oidc_redirect_uri_set: !!redirectUri, oidc_missing_fields: missing, }; } function getAdminOidcSettings() { const effectiveConfig = getOidcConfig(); return { oidc_provider_name: settingOrEnv('oidc_provider_name', 'OIDC_PROVIDER_NAME', 'authentik'), oidc_issuer_url: settingOrEnv('oidc_issuer_url', 'OIDC_ISSUER_URL', ''), oidc_client_id: settingOrEnv('oidc_client_id', 'OIDC_CLIENT_ID', ''), oidc_token_auth_method: normalizeTokenAuthMethod(settingOrEnv('oidc_token_auth_method', 'OIDC_TOKEN_AUTH_METHOD', 'client_secret_basic')), oidc_redirect_uri: settingOrEnv('oidc_redirect_uri', 'OIDC_REDIRECT_URI', ''), oidc_scopes: normalizeScopes(settingOrEnv('oidc_scopes', 'OIDC_SCOPES', 'openid email profile groups')).join(' '), oidc_admin_group: settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP', ''), oidc_default_role: 'user', oidc_auto_provision: boolSettingOrEnv('oidc_auto_provision', 'OIDC_AUTO_PROVISION', true), oidc_client_secret_set: getOidcConfigStatus().oidc_client_secret_set, oidc_env_fallback_used: !effectiveConfig ? false : ['oidc_issuer_url', 'oidc_client_id', 'oidc_client_secret', 'oidc_redirect_uri'] .some(k => !trimOrNull(getSetting(k))), }; } /** * Returns whether OIDC login is both configured and enabled by admin. */ function isOidcLoginActive() { if (getSetting('oidc_login_enabled') !== 'true') return false; return getOidcConfig() !== null; } /** Safe public OIDC info — no secrets, suitable for unauthenticated responses. */ function getPublicOidcInfo() { try { const active = isOidcLoginActive(); if (!active) return { oidc_enabled: false }; const cfg = getOidcConfig(); const providerName = cfg?.providerName || 'authentik'; return { oidc_enabled: true, oidc_provider_name: providerName, oidc_login_url: '/api/auth/oidc/login', }; } catch { return { oidc_enabled: false }; } } // ── openid-client Issuer / Client cache ─────────────────────────────────────── // Cache keyed by issuer/client/redirect so Admin config changes pick up a // fresh client. TTL prevents stale JWKS when the provider rotates keys. let _cachedClient = null; let _cachedClientKey = null; let _cacheTs = 0; const CLIENT_CACHE_TTL = 60 * 60 * 1000; // 1 hour — JWKS key rotation window /** * Returns a cached openid-client Client for the given config. * Performs OIDC discovery (fetching jwks_uri and endpoints) on first call * or when the cache has expired. Signature verification uses the discovered * JWKS URI automatically via openid-client@5. */ async function getOidcClient(config) { const now = Date.now(); const clientKey = [config.issuerUrl, config.clientId, config.redirectUri, config.tokenEndpointAuthMethod].join('|'); if ( _cachedClient && _cachedClientKey === clientKey && now - _cacheTs < CLIENT_CACHE_TTL ) { return _cachedClient; } const issuer = await Issuer.discover(config.issuerUrl); _cachedClient = new issuer.Client({ client_id: config.clientId, client_secret: config.clientSecret, token_endpoint_auth_method: config.tokenEndpointAuthMethod, redirect_uris: [config.redirectUri], response_types: ['code'], }); _cachedClientKey = clientKey; _cacheTs = now; return _cachedClient; } async function testOidcConfiguration(config = getOidcConfig()) { if (!config) { return { ok: false, error: 'OIDC configuration is incomplete.', missing: getOidcConfigStatus().oidc_missing_fields, }; } try { const client = await getOidcClient(config); const metadata = client.issuer?.metadata || {}; const missingMetadata = []; if (!metadata.authorization_endpoint) missingMetadata.push('authorization_endpoint'); if (!metadata.token_endpoint) missingMetadata.push('token_endpoint'); if (!metadata.jwks_uri) missingMetadata.push('jwks_uri'); if (missingMetadata.length) { return { ok: false, error: `OIDC discovery is missing required metadata: ${missingMetadata.join(', ')}.`, }; } return { ok: true, issuer: metadata.issuer || config.issuerUrl, provider_name: config.providerName, redirect_uri: config.redirectUri, scopes: config.scopes.join(' '), }; } catch (err) { const message = err.message || 'OIDC discovery failed.'; const looks404 = /404|not found/i.test(message); return { ok: false, error: looks404 ? 'OIDC discovery returned 404. Check the Issuer URL; it should be the provider issuer, not /authorize/ or another endpoint.' : message, }; } } /** Invalidates the client cache (e.g. after a config change or key rotation). */ function invalidateClientCache() { _cachedClient = null; _cachedClientKey = null; _cacheTs = 0; } // ── PKCE helpers ────────────────────────────────────────────────────────────── function generateCodeVerifier() { return crypto.randomBytes(32).toString('base64url'); } function generateCodeChallenge(verifier) { return crypto.createHash('sha256').update(verifier).digest('base64url'); } // ── Login state (PKCE + nonce, stored in DB with 5-minute TTL) ─────────────── const STATE_TTL_MS = 5 * 60 * 1000; function sanitizeRedirectTo(value) { if (!value || typeof value !== 'string') return null; const s = value.trim(); if (!s.startsWith('/') || s.startsWith('//') || /[:\0]/.test(s)) return null; return s.slice(0, 200); } function createLoginState(redirectTo) { const db = getDb(); const id = crypto.randomUUID(); const nonce = crypto.randomBytes(16).toString('hex'); const codeVerifier = generateCodeVerifier(); const now = new Date(); const expiresAt = new Date(now.getTime() + STATE_TTL_MS).toISOString(); db.prepare(` INSERT INTO oidc_states (id, nonce, code_verifier, redirect_to, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?) `).run(id, nonce, codeVerifier, sanitizeRedirectTo(redirectTo), now.toISOString(), expiresAt); // Prune stale states to keep the table small db.prepare("DELETE FROM oidc_states WHERE expires_at <= datetime('now')").run(); return { id, nonce, codeVerifier }; } /** Retrieve and immediately delete — prevents state replay attacks. */ function consumeLoginState(stateId) { if (!stateId || typeof stateId !== 'string') return null; const db = getDb(); const row = db.prepare(` SELECT id, nonce, code_verifier, redirect_to FROM oidc_states WHERE id = ? AND expires_at > datetime('now') `).get(stateId); if (!row) return null; db.prepare('DELETE FROM oidc_states WHERE id = ?').run(stateId); return row; } // ── Authorization URL ───────────────────────────────────────────────────────── async function buildAuthorizationUrl(config, state) { const client = await getOidcClient(config); const codeChallenge = generateCodeChallenge(state.codeVerifier); // client.authorizationUrl() uses the discovered authorization_endpoint return client.authorizationUrl({ scope: config.scopes.join(' '), redirect_uri: config.redirectUri, state: state.id, nonce: state.nonce, code_challenge: codeChallenge, code_challenge_method: 'S256', }); } // ── Token exchange + full ID token verification ─────────────────────────────── /** * Exchanges the authorization code for tokens and performs complete ID token * validation using openid-client@5: * * ✓ State parameter matches saved state (replay protection) * ✓ PKCE code_verifier / code_challenge round-trip * ✓ JWT signature verification using JWKS from OIDC discovery * ✓ Issuer validation (iss claim must match configured issuer URL) * ✓ Audience validation (aud claim must include client_id) * ✓ Expiry validation (exp must be in the future) * ✓ Not-before validation (nbf with 30s clock-skew tolerance) * ✓ Nonce validation (replay attack prevention) * ✓ sub claim present and non-empty * * Tokens are never logged. Returns verified claims on success. */ async function exchangeAndVerifyTokens(config, code, stateId, savedState) { const client = await getOidcClient(config); // client.callback() handles token exchange + full validation in one step. // It throws OPError (provider error) or RPError (validation failure) on any problem. const tokenSet = await client.callback( config.redirectUri, { code, state: stateId }, // parameters from the callback URL { code_verifier: savedState.code_verifier, nonce: savedState.nonce, state: stateId, // ensures state param matches expected value }, ); const claims = tokenSet.claims(); if (!claims.sub) throw new Error('ID token missing required sub claim'); return claims; } // ── Role / group mapping ────────────────────────────────────────────────────── /** * Maps OIDC group claims to a local role. * Admin role is NEVER granted unless the user is explicitly in the configured * admin group. Missing group claim → defaults to user role. * Default role is always user; admin is granted only by explicit group match. */ function mapRoleFromClaims(claims, config) { const adminGroup = settingOrEnv('oidc_admin_group', 'OIDC_ADMIN_GROUP') || config.adminGroup; const defaultRole = 'user'; if (!adminGroup) return defaultRole; const groups = claims.groups || []; const list = Array.isArray(groups) ? groups : [groups].filter(Boolean); return list.includes(adminGroup) ? 'admin' : defaultRole; } // ── User provisioning ───────────────────────────────────────────────────────── /** * Finds an existing user by OIDC subject, optionally links by verified email, * or auto-provisions a new user. * * Lookup order: * 1. auth_provider='oidc' + external_subject=sub (most stable) * 2. email match on existing local user (only if email_verified=true) * 3. Auto-provision if OIDC_AUTO_PROVISION=true * * OIDC-provisioned users have an empty password_hash and cannot use local * password login (enforced in authService.login()). */ async function findOrProvisionUser(claims, config) { const db = getDb(); const sub = claims.sub; const provider = 'oidc'; const autoProvision = config.autoProvision === true; // 1. Stable sub-based lookup let user = db.prepare( 'SELECT * FROM users WHERE auth_provider = ? AND external_subject = ?' ).get(provider, sub); // 2. Email-based link — only if email is present and verified if (!user && claims.email && claims.email_verified === true) { const existing = db.prepare( "SELECT * FROM users WHERE email = ? AND auth_provider = 'local' LIMIT 1" ).get(claims.email); if (existing) { db.prepare(` UPDATE users SET auth_provider = ?, external_subject = ?, email = ?, updated_at = datetime('now') WHERE id = ? `).run(provider, sub, claims.email, existing.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(existing.id); console.log(`[oidc] Linked existing local account #${user.id} to OIDC identity`); } } // 3. Auto-provision if (!user) { if (!autoProvision) { throw Object.assign( new Error('No matching account and auto-provisioning is disabled.'), { status: 403 }, ); } const role = mapRoleFromClaims(claims, config); const baseUsername = (claims.preferred_username || claims.email || sub) .slice(0, 60).replace(/[^a-zA-Z0-9._@-]/g, '_'); const displayName = claims.name || claims.preferred_username || null; const email = claims.email || null; let finalUsername = baseUsername; let suffix = 0; while (db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)) { suffix++; finalUsername = `${baseUsername}_${suffix}`; } const result = db.prepare(` INSERT INTO users (username, password_hash, role, auth_provider, external_subject, email, display_name, first_login, must_change_password, last_login_at) VALUES (?, '', ?, ?, ?, ?, ?, 0, 0, datetime('now')) `).run(finalUsername, role, provider, sub, email, displayName); user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid); console.log(`[oidc] Provisioned new ${role} user from OIDC login`); } // Refresh login timestamp and display name from provider claims db.prepare(` UPDATE users SET last_login_at = datetime('now'), display_name = COALESCE(?, display_name), updated_at = datetime('now') WHERE id = ? `).run(claims.name || null, user.id); return db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); } module.exports = { getOidcConfig, getOidcConfigStatus, getAdminOidcSettings, isOidcLoginActive, getPublicOidcInfo, getOidcClient, testOidcConfiguration, invalidateClientCache, createLoginState, consumeLoginState, buildAuthorizationUrl, exchangeAndVerifyTokens, mapRoleFromClaims, findOrProvisionUser, // Exported for testing generateCodeVerifier, generateCodeChallenge, sanitizeRedirectTo, };