diff --git a/backend/migrations.js b/backend/migrations.js index 787cffd..3246b37 100644 --- a/backend/migrations.js +++ b/backend/migrations.js @@ -218,6 +218,17 @@ async function initDatabase() { : `CREATE INDEX IF NOT EXISTS idx_uploads_user_id ON uploads(user_id)` ); + // OIDC: stabile Provider-Identität (claims.sub) pro User speichern. + // UNIQUE verhindert, dass zwei User denselben Provider-Account beanspruchen. + await migrate('add_users_oidc_sub', isPostgres + ? `ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT` + : `ALTER TABLE users ADD COLUMN oidc_sub TEXT` + ); + await migrate('idx_users_oidc_sub', isPostgres + ? `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL` + : `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc_sub ON users(oidc_sub) WHERE oidc_sub IS NOT NULL` + ); + console.log('Datenbanktabellen initialisiert.'); } diff --git a/backend/oidc.js b/backend/oidc.js new file mode 100644 index 0000000..3ead3bb --- /dev/null +++ b/backend/oidc.js @@ -0,0 +1,235 @@ +/** + * 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, +}; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 5008eab..9bffe84 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,10 +14,11 @@ "cors": "^2.8.5", "express": "^4.18.2", "express-async-errors": "^3.1.1", - "express-rate-limit": "^8.5.2", + "express-rate-limit": "^8.6.2", "helmet": "^8.2.0", "ldapts": "^8.2.0", "multer": "^2.2.0", + "openid-client": "^5.7.1", "pg": "^8.13.0", "zod": "^3.24.0" }, @@ -594,11 +595,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "license": "MIT", "dependencies": { + "debug": "^4.4.3", "ip-address": "^10.2.0" }, "engines": { @@ -611,6 +613,29 @@ "express": ">= 4.11" } }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -945,6 +970,15 @@ "node": ">=0.12.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/ldapts": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz", @@ -957,6 +991,18 @@ "node": ">=20" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1194,6 +1240,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1206,6 +1261,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1227,6 +1291,21 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1961,6 +2040,12 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/backend/package.json b/backend/package.json index c72f8ee..fb53b9e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "helmet": "^8.2.0", "ldapts": "^8.2.0", "multer": "^2.2.0", + "openid-client": "^5.7.1", "pg": "^8.13.0", "zod": "^3.24.0" }, diff --git a/backend/routes/auth.js b/backend/routes/auth.js index e768722..040672e 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -150,4 +150,40 @@ router.get('/me', authMiddleware, (req, res) => { res.json(req.user); }); +// ============ OpenID Connect (Keycloak) ============ +const oidc = require('../oidc'); + +// Status-Endpoint fürs Frontend: OIDC-Button nur anzeigen, wenn konfiguriert +router.get('/oidc/status', (req, res) => { + res.json({ configured: oidc.isOIDCConfigured() }); +}); + +// Schritt 1: Weiterleitung zu Keycloak (Authorization Code Flow + PKCE) +router.get('/oidc/login', async (req, res) => { + try { + await oidc.startAuthFlow(req, res); + } catch (err) { + console.error('[OIDC] Login-Start fehlgeschlagen:', err.message); + res.status(502).json({ error: 'Anmeldung über OIDC momentan nicht verfügbar.' }); + } +}); + +// Schritt 2: Callback von Keycloak — Code-Tausch, Provisionierung, Session +router.get('/oidc/callback', async (req, res) => { + const appBase = process.env.APP_BASE_URL || ''; + try { + await oidc.handleCallback(req, res); + // Erfolgreich: zurück zur App (Frontend übernimmt via /auth/me) + res.redirect(appBase || '/'); + } catch (err) { + console.error('[OIDC] Callback-Fehler:', err.message); + auditLog(null, 'oidc.login_failed', null, null, err.message, req); + const status = err.status || 502; + // Dem Nutzer einen verständlichen Fehler zeigen (SPA-Fallback rendert index.html; + // die Meldung geht als Query-Parameter mit und wird vom Login-Formular angezeigt) + const msg = encodeURIComponent(err.message || 'OIDC-Anmeldung fehlgeschlagen.'); + res.redirect((appBase || '/') + '?oidcError=' + msg); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 9eab90b..9dca1a8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -113,8 +113,9 @@ app.use(cookieParser()); // P4: CSRF protection for state-changing requests (Double-Submit-Cookie) // Skip CSRF check for login/register (no session yet, no CSRF token available) +// and OIDC flow endpoints (GET-Redirects; der Flow selbst ist per State-Cookie geschützt) app.use('/api', (req, res, next) => { - if (req.path.startsWith('/auth/login') || req.path.startsWith('/auth/register') || req.path.startsWith('/v1/auth/login') || req.path.startsWith('/v1/auth/register')) { + if (req.path.startsWith('/auth/login') || req.path.startsWith('/auth/register') || req.path.startsWith('/v1/auth/login') || req.path.startsWith('/v1/auth/register') || req.path.startsWith('/auth/oidc/') || req.path.startsWith('/v1/auth/oidc/')) { return next(); } csrfMiddleware(req, res, next); diff --git a/docker-compose.stack.yml b/docker-compose.stack.yml index 5bdd7a1..c0b087a 100644 --- a/docker-compose.stack.yml +++ b/docker-compose.stack.yml @@ -24,6 +24,13 @@ services: - LDAP_ATTRIBUTES=${LDAP_ATTRIBUTES:-mail,displayName,memberOf,distinguishedName,sAMAccountName} - LDAP_CREATE_OU=${LDAP_CREATE_OU:-} - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} + # OIDC (Keycloak) — leer = deaktiviert, Login-Button wird nicht angezeigt + - OIDC_ISSUER=${OIDC_ISSUER:-} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} + - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-} + - OIDC_LOGOUT_URL=${OIDC_LOGOUT_URL:-} + - APP_BASE_URL=${APP_BASE_URL:-} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} # M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion) diff --git a/docker-compose.yml b/docker-compose.yml index 9fc8a4b..e5bf2e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,13 @@ services: - LDAP_ATTRIBUTES=${LDAP_ATTRIBUTES:-mail,displayName,memberOf,distinguishedName,sAMAccountName} - LDAP_CREATE_OU=${LDAP_CREATE_OU:-} - LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-} + # OIDC (Keycloak) — leer = deaktiviert, Login-Button wird nicht angezeigt + - OIDC_ISSUER=${OIDC_ISSUER:-} + - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-} + - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-} + - OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-} + - OIDC_LOGOUT_URL=${OIDC_LOGOUT_URL:-} + - APP_BASE_URL=${APP_BASE_URL:-} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local} - ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-} # M3: Session-Secret für CSRF-Token-Ableitung (Pflicht in Produktion) diff --git a/frontend/src/components/AuthModal.jsx b/frontend/src/components/AuthModal.jsx index 8536207..18ecdb5 100644 --- a/frontend/src/components/AuthModal.jsx +++ b/frontend/src/components/AuthModal.jsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useAuth } from '../context/AuthContext'; +import { API_BASE } from '../utils/api'; export default function AuthModal() { const { login, register } = useAuth(); @@ -11,6 +12,22 @@ export default function AuthModal() { const [success, setSuccess] = useState(''); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [oidcConfigured, setOidcConfigured] = useState(false); + + // OIDC-Verfügbarkeit + Fehler aus dem Callback (als Query-Parameter) laden + useEffect(() => { + fetch(`${API_BASE}/auth/oidc/status`) + .then((res) => (res.ok ? res.json() : { configured: false })) + .then((data) => setOidcConfigured(!!data.configured)) + .catch(() => setOidcConfigured(false)); + const params = new URLSearchParams(window.location.search); + const oidcError = params.get('oidcError'); + if (oidcError) { + setError(oidcError); + // Parameter aus der URL entfernen (sauberer Zustand bei Reload) + window.history.replaceState({}, '', window.location.pathname); + } + }, []); const handleSubmit = async (e) => { e.preventDefault(); @@ -138,6 +155,20 @@ export default function AuthModal() { + + {/* OIDC (Keycloak) — nur angezeigt, wenn serverseitig konfiguriert */} + {oidcConfigured && ( + <> +
Single Sign-On
+ + + Mit Unternehmens-Konto anmelden + + + )}