diff --git a/README.md b/README.md index 2e6790e..5268a58 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ npm run dev Auth - `rms.auth.smtp_relay`: email link challenge delivery through SMTP relay - `rms.auth.otp_email`: OTP challenge delivery through email +- `rms.auth.oauth`: OAuth/OIDC authorization-code login (disabled by default; configurable authorize/token/userinfo/client settings) Station & Access - `rms.station.shell`: station activation/deactivation command execution diff --git a/plugins/rms.auth.oauth/index.js b/plugins/rms.auth.oauth/index.js new file mode 100644 index 0000000..6da42fe --- /dev/null +++ b/plugins/rms.auth.oauth/index.js @@ -0,0 +1,212 @@ +async function createPlugin(ctx) { + return { + async execute(action, input) { + if (action === "start_oauth") { + return startOauth(ctx, input || {}); + } + if (action === "finish_oauth") { + return finishOauth(ctx, input || {}); + } + throw new Error(`Unknown action: ${action}`); + }, + async health() { + const cfg = readConfig(ctx, null, null); + const ok = Boolean(cfg.authorizeUrl && cfg.tokenUrl && cfg.clientId); + return { + ok, + message: ok ? "configured" : "OAuth settings incomplete" + }; + } + }; +} + +function readConfig(ctx, origin, callbackUrl) { + const configuredRedirect = String(ctx.getSetting("redirectUri", "")).trim(); + const effectiveRedirect = configuredRedirect || String(callbackUrl || "").trim() || `${String(origin || "").replace(/\/+$/, "")}/v1/auth/oauth/callback`; + return { + authorizeUrl: String(ctx.getSetting("authorizeUrl", "")).trim(), + tokenUrl: String(ctx.getSetting("tokenUrl", "")).trim(), + userInfoUrl: String(ctx.getSetting("userInfoUrl", "")).trim(), + clientId: String(ctx.getSetting("clientId", "")).trim(), + clientSecret: String(ctx.getSetting("clientSecret", "")).trim(), + scope: String(ctx.getSetting("scope", "openid profile email")).trim() || "openid profile email", + redirectUri: effectiveRedirect, + emailField: String(ctx.getSetting("emailField", "email")).trim() || "email", + authStyle: String(ctx.getSetting("authStyle", "body")).trim().toLowerCase() === "basic" ? "basic" : "body", + audience: String(ctx.getSetting("audience", "")).trim(), + extraAuthorizeParams: parseJsonObject(ctx.getSetting("extraAuthorizeParams", "")), + extraTokenParams: parseJsonObject(ctx.getSetting("extraTokenParams", "")) + }; +} + +function parseJsonObject(raw) { + const text = String(raw || "").trim(); + if (!text) return {}; + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + // ignore malformed JSON in optional params + } + return {}; +} + +function ensureConfigured(cfg) { + if (!cfg.authorizeUrl) throw new Error("OAuth authorizeUrl missing"); + if (!cfg.tokenUrl) throw new Error("OAuth tokenUrl missing"); + if (!cfg.clientId) throw new Error("OAuth clientId missing"); + if (!cfg.redirectUri) throw new Error("OAuth redirectUri missing"); +} + +async function startOauth(ctx, input) { + const payload = input && input.payload ? input.payload : {}; + const origin = input && input.origin ? String(input.origin) : ""; + const callbackUrl = payload && payload.callbackUrl ? String(payload.callbackUrl) : ""; + const cfg = readConfig(ctx, origin, callbackUrl); + ensureConfigured(cfg); + const state = String(payload && payload.state ? payload.state : "").trim(); + if (!state) { + throw new Error("OAuth state missing"); + } + + const params = new URLSearchParams(); + params.set("response_type", "code"); + params.set("client_id", cfg.clientId); + params.set("redirect_uri", cfg.redirectUri); + params.set("scope", cfg.scope); + params.set("state", state); + if (cfg.audience) { + params.set("audience", cfg.audience); + } + for (const [key, value] of Object.entries(cfg.extraAuthorizeParams)) { + params.set(String(key), String(value)); + } + + const separator = cfg.authorizeUrl.includes("?") ? "&" : "?"; + return { + ok: true, + authorizeUrl: `${cfg.authorizeUrl}${separator}${params.toString()}`, + redirectUri: cfg.redirectUri + }; +} + +async function finishOauth(ctx, input) { + const payload = input && input.payload ? input.payload : {}; + const origin = input && input.origin ? String(input.origin) : ""; + const callbackUrl = payload && payload.callbackUrl ? String(payload.callbackUrl) : ""; + const cfg = readConfig(ctx, origin, callbackUrl); + ensureConfigured(cfg); + + const code = String(payload && payload.code ? payload.code : "").trim(); + if (!code) { + throw new Error("OAuth code missing"); + } + + const tokenPayload = new URLSearchParams(); + tokenPayload.set("grant_type", "authorization_code"); + tokenPayload.set("code", code); + tokenPayload.set("client_id", cfg.clientId); + tokenPayload.set("redirect_uri", cfg.redirectUri); + if (cfg.authStyle !== "basic" && cfg.clientSecret) { + tokenPayload.set("client_secret", cfg.clientSecret); + } + for (const [key, value] of Object.entries(cfg.extraTokenParams)) { + tokenPayload.set(String(key), String(value)); + } + + const tokenHeaders = { + "Content-Type": "application/x-www-form-urlencoded" + }; + if (cfg.authStyle === "basic" && cfg.clientSecret) { + const basic = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString("base64"); + tokenHeaders.Authorization = `Basic ${basic}`; + } + + const tokenResponse = await fetch(cfg.tokenUrl, { + method: "POST", + headers: tokenHeaders, + body: tokenPayload.toString() + }); + const tokenBody = await parseJsonResponse(tokenResponse); + if (!tokenResponse.ok) { + throw new Error(`OAuth token exchange failed (${tokenResponse.status})`); + } + const accessToken = String(tokenBody.access_token || "").trim(); + if (!accessToken) { + throw new Error("OAuth token exchange returned no access_token"); + } + + let profile = null; + if (cfg.userInfoUrl) { + const profileResponse = await fetch(cfg.userInfoUrl, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + profile = await parseJsonResponse(profileResponse); + if (!profileResponse.ok) { + throw new Error(`OAuth userinfo failed (${profileResponse.status})`); + } + } else { + const idTokenPayload = decodeJwtPayload(String(tokenBody.id_token || "")); + if (!idTokenPayload) { + throw new Error("OAuth profile missing (set userInfoUrl or provide id_token)"); + } + profile = idTokenPayload; + } + + const email = resolveEmail(profile, cfg.emailField); + if (!email) { + throw new Error(`OAuth profile contains no email field (${cfg.emailField})`); + } + + return { + ok: true, + email, + profile + }; +} + +async function parseJsonResponse(response) { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +function decodeJwtPayload(token) { + const parts = String(token || "").split("."); + if (parts.length < 2) return null; + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = payload + "=".repeat((4 - (payload.length % 4 || 4)) % 4); + try { + const json = Buffer.from(padded, "base64").toString("utf8"); + return JSON.parse(json); + } catch { + return null; + } +} + +function resolveEmail(profile, fieldPath) { + const fallback = typeof profile.email === "string" ? profile.email : ""; + const path = String(fieldPath || "email").trim(); + if (!path) { + return normalizeEmail(fallback); + } + const value = path.split(".").reduce((acc, key) => { + if (!acc || typeof acc !== "object") return undefined; + return acc[key]; + }, profile); + return normalizeEmail(typeof value === "string" ? value : fallback); +} + +function normalizeEmail(value) { + return String(value || "").trim().toLowerCase(); +} + +module.exports = { + createPlugin +}; diff --git a/plugins/rms.auth.oauth/manifest.json b/plugins/rms.auth.oauth/manifest.json new file mode 100644 index 0000000..5c58037 --- /dev/null +++ b/plugins/rms.auth.oauth/manifest.json @@ -0,0 +1,32 @@ +{ + "id": "rms.auth.oauth", + "name": "OAuth / OIDC Auth", + "version": "1.0.0", + "apiVersion": "1.0", + "defaultEnabled": false, + "capabilities": [], + "authMethod": { + "id": "oauth", + "type": "oauth", + "label": "OAuth" + }, + "settingsSchema": { + "type": "object", + "properties": { + "authorizeUrl": { "type": "string" }, + "tokenUrl": { "type": "string" }, + "userInfoUrl": { "type": "string" }, + "clientId": { "type": "string" }, + "clientSecret": { "type": "string" }, + "scope": { "type": "string" }, + "redirectUri": { "type": "string" }, + "emailField": { "type": "string" }, + "authStyle": { "type": "string" }, + "audience": { "type": "string" }, + "extraAuthorizeParams": { "type": "string" }, + "extraTokenParams": { "type": "string" } + }, + "additionalProperties": false + }, + "uiControls": [] +} diff --git a/public/app.js b/public/app.js index c5ec873..328a9af 100644 --- a/public/app.js +++ b/public/app.js @@ -903,6 +903,10 @@ async function requestAccess() { body: { email, method }, authRequired: false }); + if (result && result.challengeType === "oauth" && result.authorizeUrl) { + window.location.assign(String(result.authorizeUrl)); + return; + } els.otpWrap.hidden = result.challengeType !== "otp"; renderMessage(els.authMessage, result.message || "Bitte E-Mail pruefen.", false, true); } catch (error) { @@ -984,6 +988,15 @@ async function handleEmailTokenFromUrl() { } const token = url.searchParams.get("verifyToken") || url.searchParams.get("loginToken"); + const authError = url.searchParams.get("authError"); + if (authError) { + const authMessage = url.searchParams.get("authMessage") || "OAuth Anmeldung fehlgeschlagen."; + renderMessage(els.authMessage, authMessage, true); + url.searchParams.delete("authError"); + url.searchParams.delete("authMessage"); + window.history.replaceState({}, "", `${url.pathname}${url.search}`); + return; + } if (!token) { return; } diff --git a/server/index.js b/server/index.js index d59e5b7..00d9f47 100644 --- a/server/index.js +++ b/server/index.js @@ -151,7 +151,8 @@ const runtime = { refreshTokens: [], tokenVersionByUser: {}, emailTokens: [], - otpChallenges: [] + otpChallenges: [], + oauthChallenges: [] }, approvalRequests: [], systemState: { @@ -345,6 +346,10 @@ async function routeRequest(req, res) { return handleLogoutAll(res, auth.user); } + if (method === "GET" && url.pathname === "/v1/auth/oauth/callback") { + return handleOauthCallback(req, res, url); + } + if (method === "GET" && url.pathname === "/v1/me") { const auth = requireAuth(req, res); if (!auth) return; @@ -1042,6 +1047,51 @@ async function handleRequestAccess(req, res, body) { html: message.html, code: otpCode }); + } else if (selectedMethod.type === "oauth") { + const purpose = user.status === "active" ? "login" : "verify"; + const stateToken = await issueOauthChallenge(user.id, purpose, selectedMethod.id); + const plugin = runtime.plugins.get(selectedMethod.pluginId); + if (!plugin || typeof plugin.instance.execute !== "function") { + return sendError(res, 409, "auth.oauth_not_configured", "OAuth Provider ist nicht konfiguriert"); + } + const callbackUrl = `${publicBaseUrlFor(req)}/v1/auth/oauth/callback`; + const oauthResult = await plugin.instance.execute("start_oauth", { + methodId: selectedMethod.id, + methodType: selectedMethod.type, + user: sanitizeUser(user), + recipient: user.email, + origin: publicBaseUrlFor(req), + payload: { + purpose, + state: stateToken, + callbackUrl + } + }, { user }); + const authorizeUrl = oauthResult && oauthResult.authorizeUrl ? String(oauthResult.authorizeUrl) : ""; + if (!authorizeUrl) { + return sendError(res, 409, "auth.oauth_not_configured", "OAuth Provider liefert keine Authorize-URL"); + } + challengeType = "oauth"; + challengeHint = `Anmeldung ueber ${selectedMethod.label}`; + await appendAudit("auth.request_access", user, { + status: user.status, + requestedMethod: requestedMethod || null, + method: selectedMethod.id, + challengeType, + oauthRedirect: true + }); + return sendJson(res, 200, { + ok: true, + method: selectedMethod.id, + challengeType, + challengeHint, + authorizeUrl, + message: "Weiterleitung zum OAuth-Provider...", + domainAllowed: isPrimaryDomainEmail(email), + requestApprovalHint: !isPrimaryDomainEmail(email) + ? "Adresse ausserhalb der Hauptdomain: Nach Bestaetigung wird eine Freigabe angefordert." + : null + }); } else { return sendError(res, 400, "auth.method_invalid", "Unbekannte Bestaetigungsart"); } @@ -1096,45 +1146,88 @@ async function handleVerifyEmail(req, res, body) { return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden"); } - if (verification.purpose === "verify") { - user.emailVerifiedAt = new Date().toISOString(); - if (user.accountType === "primary-domain" || user.role === "admin") { - user.status = "active"; - user.approvedAt = new Date().toISOString(); - user.deniedAt = null; - await writeJson(files.users, runtime.users); - await appendAudit("auth.verified_auto_approved", user, null); - } else { - user.status = "pending_approval"; - user.deniedAt = null; - await writeJson(files.users, runtime.users); - await createApprovalRequest(user, req); - await appendAudit("auth.verified_pending_approval", user, null); - return sendJson(res, 200, { - ok: true, - verified: true, - approved: false, - message: "E-Mail bestaetigt. Kein direkter Zugriff fuer diese Domain. Freigabe wurde angefordert." + const result = await finalizeLoginForVerification(req, user, verification.purpose, verification.purpose); + if (result && result.error) { + return sendError(res, result.error.status, result.error.code, result.error.message); + } + return sendJson(res, 200, result); +} + +async function handleOauthCallback(req, res, url) { + const error = String(url.searchParams.get("error") || "").trim(); + const errorDescription = String(url.searchParams.get("error_description") || "").trim(); + const stateToken = String(url.searchParams.get("state") || "").trim(); + const code = String(url.searchParams.get("code") || "").trim(); + + if (error) { + return redirectToLoginWithAuthError(res, `oauth_${error}`, errorDescription || "OAuth Login fehlgeschlagen"); + } + if (!stateToken || !code) { + return redirectToLoginWithAuthError(res, "auth.oauth_missing", "OAuth Parameter fehlen"); + } + + const challenge = consumeOauthChallenge(stateToken); + if (!challenge.ok) { + return redirectToLoginWithAuthError(res, "auth.oauth_invalid", challenge.message || "OAuth state ungueltig"); + } + + const user = runtime.users.find((entry) => String(entry.id || "") === String(challenge.userId || "")); + if (!user) { + return redirectToLoginWithAuthError(res, "auth.user_not_found", "Benutzer nicht gefunden"); + } + + const method = findPublicAuthMethodById(challenge.methodId); + if (!method || method.type !== "oauth") { + return redirectToLoginWithAuthError(res, "auth.oauth_method_invalid", "OAuth Methode ist nicht verfuegbar"); + } + + const plugin = runtime.plugins.get(method.pluginId); + if (!plugin || typeof plugin.instance.execute !== "function") { + return redirectToLoginWithAuthError(res, "auth.oauth_not_configured", "OAuth Provider ist nicht konfiguriert"); + } + + try { + const callbackUrl = `${publicBaseUrlFor(req)}/v1/auth/oauth/callback`; + const oauthResult = await plugin.instance.execute("finish_oauth", { + methodId: method.id, + methodType: method.type, + user: sanitizeUser(user), + recipient: user.email, + origin: publicBaseUrlFor(req), + payload: { + code, + state: stateToken, + callbackUrl, + challengePurpose: challenge.purpose + } + }, { user }); + + const oauthEmail = normalizeEmail(oauthResult && oauthResult.email ? oauthResult.email : user.email); + if (!oauthEmail || oauthEmail !== normalizeEmail(user.email)) { + await appendAudit("auth.oauth.email_mismatch", user, { + method: method.id, + oauthEmail: oauthEmail || null }); + return redirectToLoginWithAuthError(res, "auth.oauth_email_mismatch", "OAuth E-Mail passt nicht zum angeforderten Konto"); } - } - if (runtime.systemState.maintenanceMode && user.role !== "admin") { - return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage); + const finalPurpose = challenge.purpose === "verify" ? "verifyToken" : "loginToken"; + const token = await issueEmailToken(user.id, challenge.purpose === "verify" ? "verify" : "login"); + await appendAudit("auth.oauth.callback", user, { + method: method.id, + purpose: challenge.purpose + }); + res.writeHead(302, { Location: `/login?${finalPurpose}=${encodeURIComponent(token)}` }); + res.end(); + } catch (oauthError) { + const message = String(oauthError && oauthError.message ? oauthError.message : oauthError); + await appendAudit("auth.oauth.callback.failed", user, { + method: method.id, + purpose: challenge.purpose, + error: message + }); + return redirectToLoginWithAuthError(res, "auth.oauth_failed", message || "OAuth Callback fehlgeschlagen"); } - if (user.status !== "active") { - return sendError(res, 403, "auth.not_approved", "Benutzer ist noch nicht freigegeben"); - } - - const tokens = await issueTokenPair(user, req); - await appendAudit("auth.login", user, { sid: tokens.sid, via: verification.purpose }); - return sendJson(res, 200, { - ok: true, - user: sanitizeUser(user), - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresInSec: config.accessTokenTtlSec - }); } async function handleRequestApproval(req, res, body) { @@ -1925,6 +2018,116 @@ function resolveAuthMethodForUser(user, requestedMethodId) { return allowed[0] || null; } +function findPublicAuthMethodById(methodId) { + const needle = String(methodId || "").trim(); + if (!needle) { + return null; + } + return listPublicAuthMethods().find((entry) => entry.id === needle) || null; +} + +async function issueOauthChallenge(userId, purpose, methodId) { + const state = crypto.randomBytes(24).toString("base64url"); + runtime.authState.oauthChallenges.push({ + id: crypto.randomUUID(), + userId, + purpose, + methodId, + state, + createdAt: new Date().toISOString(), + expiresAtMs: Date.now() + 10 * 60 * 1000, + consumedAt: null + }); + pruneOauthChallenges(); + await saveAuthState(); + return state; +} + +function consumeOauthChallenge(state) { + const token = String(state || "").trim(); + if (!token) { + return { ok: false, message: "OAuth state fehlt" }; + } + const now = Date.now(); + const record = runtime.authState.oauthChallenges + .filter((entry) => !entry.consumedAt && now <= Number(entry.expiresAtMs || 0)) + .find((entry) => safeEquals(String(entry.state || ""), token)); + if (!record) { + return { ok: false, message: "OAuth state ungueltig oder abgelaufen" }; + } + record.consumedAt = new Date().toISOString(); + saveAuthState().catch(() => {}); + return { + ok: true, + userId: record.userId, + purpose: record.purpose, + methodId: record.methodId, + state: record.state + }; +} + +function pruneOauthChallenges() { + const now = Date.now(); + runtime.authState.oauthChallenges = runtime.authState.oauthChallenges.filter((entry) => { + if (entry.consumedAt && now - new Date(entry.consumedAt).getTime() > 24 * 60 * 60 * 1000) { + return false; + } + return Number(entry.expiresAtMs || 0) > now - 24 * 60 * 60 * 1000; + }); +} + +async function finalizeLoginForVerification(req, user, purpose, via) { + if (purpose === "verify") { + user.emailVerifiedAt = new Date().toISOString(); + if (user.accountType === "primary-domain" || user.role === "admin") { + user.status = "active"; + user.approvedAt = new Date().toISOString(); + user.deniedAt = null; + await writeJson(files.users, runtime.users); + await appendAudit("auth.verified_auto_approved", user, null); + } else { + user.status = "pending_approval"; + user.deniedAt = null; + await writeJson(files.users, runtime.users); + await createApprovalRequest(user, req); + await appendAudit("auth.verified_pending_approval", user, null); + return { + ok: true, + verified: true, + approved: false, + message: "E-Mail bestaetigt. Kein direkter Zugriff fuer diese Domain. Freigabe wurde angefordert." + }; + } + } + + if (runtime.systemState.maintenanceMode && user.role !== "admin") { + return { error: { status: 503, code: "auth.maintenance", message: runtime.systemState.maintenanceMessage } }; + } + if (user.status !== "active") { + return { error: { status: 403, code: "auth.not_approved", message: "Benutzer ist noch nicht freigegeben" } }; + } + + const tokens = await issueTokenPair(user, req); + await appendAudit("auth.login", user, { sid: tokens.sid, via: via || purpose }); + return { + ok: true, + user: sanitizeUser(user), + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresInSec: config.accessTokenTtlSec + }; +} + +function redirectToLoginWithAuthError(res, code, message) { + const params = new URLSearchParams(); + params.set("authError", String(code || "auth.oauth_failed")); + if (message) { + params.set("authMessage", String(message)); + } + res.writeHead(302, { Location: `/login?${params.toString()}` }); + res.end(); +} + async function dispatchAuthChallenge(req, user, method, payload) { const plugin = runtime.plugins.get(method.pluginId); if (!plugin || typeof plugin.instance.execute !== "function") { @@ -6445,7 +6648,9 @@ async function loadPlugins() { for (const [id, plugin] of runtime.plugins.entries()) { if (!(id in runtime.pluginState.enabled)) { - runtime.pluginState.enabled[id] = true; + runtime.pluginState.enabled[id] = plugin && plugin.manifest && typeof plugin.manifest.defaultEnabled === "boolean" + ? plugin.manifest.defaultEnabled + : true; } if (runtime.pluginState.enabled[id] && plugin.instance.start) { await plugin.instance.start(); @@ -7040,6 +7245,10 @@ async function applyAdminRoles() { runtime.authState.otpChallenges = []; changed = true; } + if (!Array.isArray(runtime.authState.oauthChallenges)) { + runtime.authState.oauthChallenges = []; + changed = true; + } const availableMethods = listPublicAuthMethods(); const availableMethodIds = availableMethods.map((entry) => entry.id); const defaultMethodId = preferredAuthMethodId(availableMethods);