235 lines
9.2 KiB
JavaScript
235 lines
9.2 KiB
JavaScript
/**
|
|
* 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,
|
|
}; |