/** * OpenID Connect (OIDC) Module — Keycloak-Integration. * * Authorization Code Flow mit PKCE: * 1. GET /api/auth/oidc/login → erzeugt State+Nonce+PKCE, leitet zu Keycloak weiter * 2. Keycloak authentifiziert den Nutzer, leitet zurück auf /api/auth/oidc/callback * 3. Callback validiert Code+State, tauscht den Code gegen Tokens, * provisioniert den User lokal und erstellt eine App-Session (HttpOnly-Cookie) * * Sicherheitsprinzipien: * - State/Nonce in kurzlebigen, HttpOnly-Cookies (kein Server-Side-Store nötig) * - PKCE (S256) gegen Authorization-Code-Interception * - ID-Token-Signatur wird von openid-client gegen die JWKS des Providers geprüft * - Rollen bleiben app-seitig (siehe Rollen-Konzept): neue OIDC-User starten * immer als 'user' + 'inaktiv' und müssen von einem Admin freigeschaltet werden * * ENV: * OIDC_ISSUER - z.B. https://keycloak.example.com/realms/workflow * OIDC_CLIENT_ID - Client-ID in Keycloak * OIDC_CLIENT_SECRET - Client-Secret (confidential client) * OIDC_REDIRECT_URI - z.B. https://workflow.example.com/api/auth/oidc/callback * OIDC_LOGOUT_URL - optional: Keycloak-Logout-Endpoint (RP-initiated logout) * APP_BASE_URL - öffentliche Basis-URL der App (für Redirect nach Logout) */ const { Issuer, generators } = require('openid-client'); const crypto = require('crypto'); const db = require('./db'); const { auditLog } = require('./auditLog'); const { createSession, setAuthCookie, setCSRFCookie, hashToken } = require('./middleware/auth'); const OIDC_ISSUER = process.env.OIDC_ISSUER || ''; const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ''; const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ''; const OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI || ''; const APP_BASE_URL = process.env.APP_BASE_URL || ''; function isOIDCConfigured() { return !!(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET && OIDC_REDIRECT_URI); } // Discovery wird einmalig gecached (Issuer.discover ist idempotent, aber wir // wollen den Client-Objekt-Overhead pro Request vermeiden) let clientPromise = null; async function getClient() { if (!isOIDCConfigured()) { throw new Error('OIDC nicht konfiguriert.'); } if (!clientPromise) { clientPromise = Issuer.discover(OIDC_ISSUER).then((issuer) => { const client = new issuer.Client({ client_id: OIDC_CLIENT_ID, client_secret: OIDC_CLIENT_SECRET, redirect_uris: [OIDC_REDIRECT_URI], response_types: ['code'], }); console.log('[OIDC] Discovery erfolgreich:', issuer.issuer); return client; }).catch((err) => { // Bei Fehler Cache verwerfen, damit ein Retry möglich ist clientPromise = null; throw new Error('OIDC Discovery fehlgeschlagen: ' + err.message); }); } return clientPromise; } // ============ State/Nonce/PKCE-Handling (kurzlebige HttpOnly-Cookies) ============ const OIDC_STATE_COOKIE = 'oidc_state'; const OIDC_NONCE_COOKIE = 'oidc_nonce'; const OIDC_VERIFIER_COOKIE = 'oidc_verifier'; const OIDC_COOKIE_MAX_AGE = 10 * 60 * 1000; // 10 Minuten const oidcCookieOptions = (maxAge) => ({ httpOnly: true, secure: process.env.COOKIE_SECURE === 'true' || (process.env.COOKIE_SECURE === undefined && process.env.NODE_ENV === 'production'), sameSite: 'lax', // lax ist nötig, damit der Redirect von Keycloak das Cookie mitbringt maxAge, path: '/api/auth/oidc', }); function setFlowCookies(res, state, nonce, verifier) { res.cookie(OIDC_STATE_COOKIE, state, oidcCookieOptions(OIDC_COOKIE_MAX_AGE)); res.cookie(OIDC_NONCE_COOKIE, nonce, oidcCookieOptions(OIDC_COOKIE_MAX_AGE)); res.cookie(OIDC_VERIFIER_COOKIE, verifier, oidcCookieOptions(OIDC_COOKIE_MAX_AGE)); } function clearFlowCookies(res) { const opts = { ...oidcCookieOptions(0), maxAge: 0 }; res.clearCookie(OIDC_STATE_COOKIE, opts); res.clearCookie(OIDC_NONCE_COOKIE, opts); res.clearCookie(OIDC_VERIFIER_COOKIE, opts); } /** * Startet den Authorization-Code-Flow: leitet den Browser zu Keycloak weiter. */ async function startAuthFlow(req, res) { const client = await getClient(); const state = generators.state(); const nonce = generators.nonce(); const codeVerifier = generators.codeVerifier(); setFlowCookies(res, state, nonce, codeVerifier); const authorizationUrl = client.authorizationUrl({ scope: 'openid email profile', state, nonce, code_challenge: generators.codeChallenge(codeVerifier), code_challenge_method: 'S256', }); auditLog(null, 'oidc.login_start', null, null, 'OIDC authorization flow gestartet', req); res.redirect(authorizationUrl); } /** * Provisioniert den OIDC-User in der lokalen DB. * Identität: claims.sub (stabil), Fallback-Matching über E-Mail. * Rollen/Status bleiben app-seitig: Neuanlage immer 'user' + 'inaktiv'. */ async function provisionUser(claims, req) { const sub = String(claims.sub || ''); const email = String(claims.email || '').toLowerCase().trim(); const preferredUsername = String(claims.preferred_username || '').toLowerCase().trim(); const name = String(claims.name || claims.given_name || preferredUsername || email || 'OIDC-User'); if (!sub) { throw new Error('OIDC-Claims ohne sub — Login abgelehnt.'); } if (!email) { throw new Error('OIDC-Claims ohne E-Mail — Login abgelehnt (email-Scope erforderlich).'); } // 1. Primär: Identität über oidc_sub (stabil, auch bei E-Mail-Wechsel) let user = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE oidc_sub = ?').get(sub); // 2. Fallback: bestehender User mit gleicher E-Mail wird verknüpft if (!user) { user = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(email) = LOWER(?)').get(email); if (user) { await db.prepare('UPDATE users SET oidc_sub = ? WHERE id = ?').run(sub, user.id); console.log('[OIDC] Bestehenden User mit oidc_sub verknüpft:', email); } } // 3. Neuanlage: immer 'user' + 'inaktiv' (Admin muss freischalten) if (!user) { const info = await db.prepare( 'INSERT INTO users (email, password, name, role, status, source, username, oidc_sub) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'oidc\', ?, ?)' ).run(email, 'OIDC_AUTH', name, preferredUsername || email.split('@')[0], sub); user = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE id = ?').get(info.lastInsertRowid); auditLog(user.id, 'oidc.register', 'user', user.id, `Neuer OIDC-User: ${email}`, req); } // Namen aktuell halten (Rolle/Status bleiben app-seitig — bewusst NICHT überschrieben) if (user.name !== name) { await db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name, user.id); } return user; } /** * Verarbeitet den OIDC-Callback: Code-Tausch, Claims-Prüfung, Session-Erstellung. * Gibt den User zurück; die Antwort (Redirect) schreibt die aufrufende Route. */ async function handleCallback(req, res) { const client = await getClient(); // CSRF-Schutz des Flows: State aus Cookie muss mit Query übereinstimmen const cookieState = req.cookies?.[OIDC_STATE_COOKIE]; const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE]; const cookieVerifier = req.cookies?.[OIDC_VERIFIER_COOKIE]; clearFlowCookies(res); if (!cookieState || !cookieNonce || !cookieVerifier) { throw new Error('OIDC-Flow abgelaufen oder manipuliert (fehlende Flow-Cookies).'); } if (!req.query.state || req.query.state !== cookieState) { throw new Error('OIDC-State stimmt nicht überein (möglicher CSRF-Versuch).'); } if (req.query.error) { throw new Error(`OIDC-Fehler vom Provider: ${req.query.error_description || req.query.error}`); } const params = client.callbackParams(req); const tokenSet = await client.callback(OIDC_REDIRECT_URI, params, { state: cookieState, nonce: cookieNonce, code_verifier: cookieVerifier, }); const claims = tokenSet.claims(); const user = await provisionUser(claims, req); if (user.status === 'inaktiv') { auditLog(user.id, 'oidc.login_denied', 'user', user.id, 'OIDC-Login abgelehnt: Konto inaktiv', req); const err = new Error('Konto noch nicht freigeschaltet. Ein Administrator muss dein Konto freischalten.'); err.status = 403; throw err; } // Session-Rotation: alte Session invalidieren (V6-Konzept), neue erstellen const oldRawToken = req.cookies?.workflow_token || null; const rawToken = await createSession(user.id, oldRawToken); setAuthCookie(res, rawToken); setCSRFCookie(res, hashToken(rawToken)); auditLog(user.id, 'oidc.login', 'user', user.id, 'OIDC-Login erfolgreich', req); return user; } /** * RP-initiated Logout: lokale Session wird vom Aufrufer gelöscht; hier wird * nur die Keycloak-Logout-URL gebaut (optional mit id_token_hint). */ function buildLogoutUrl(idTokenHint) { if (!process.env.OIDC_LOGOUT_URL) return null; const url = new URL(process.env.OIDC_LOGOUT_URL); if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint); if (APP_BASE_URL) url.searchParams.set('post_logout_redirect_uri', APP_BASE_URL); return url.toString(); } module.exports = { isOIDCConfigured, startAuthFlow, handleCallback, buildLogoutUrl, OIDC_STATE_COOKIE, OIDC_NONCE_COOKIE, OIDC_VERIFIER_COOKIE, };