add configurable OAuth auth plugin support

Introduce a new rms.auth.oauth auth method plugin for OAuth/OIDC code flow with standard provider settings (authorize/token/userinfo URLs, client credentials, scope, redirect URI and extra params). Add server callback handling and OAuth challenge state tracking, UI redirect/error handling, and keep the plugin disabled by default via manifest defaultEnabled=false.
This commit is contained in:
2026-03-16 12:57:30 +01:00
parent 6342b40369
commit 2b05057aa2
5 changed files with 504 additions and 37 deletions

View File

@@ -36,6 +36,7 @@ npm run dev
Auth Auth
- `rms.auth.smtp_relay`: email link challenge delivery through SMTP relay - `rms.auth.smtp_relay`: email link challenge delivery through SMTP relay
- `rms.auth.otp_email`: OTP challenge delivery through email - `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 Station & Access
- `rms.station.shell`: station activation/deactivation command execution - `rms.station.shell`: station activation/deactivation command execution

View File

@@ -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
};

View File

@@ -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": []
}

View File

@@ -903,6 +903,10 @@ async function requestAccess() {
body: { email, method }, body: { email, method },
authRequired: false authRequired: false
}); });
if (result && result.challengeType === "oauth" && result.authorizeUrl) {
window.location.assign(String(result.authorizeUrl));
return;
}
els.otpWrap.hidden = result.challengeType !== "otp"; els.otpWrap.hidden = result.challengeType !== "otp";
renderMessage(els.authMessage, result.message || "Bitte E-Mail pruefen.", false, true); renderMessage(els.authMessage, result.message || "Bitte E-Mail pruefen.", false, true);
} catch (error) { } catch (error) {
@@ -984,6 +988,15 @@ async function handleEmailTokenFromUrl() {
} }
const token = url.searchParams.get("verifyToken") || url.searchParams.get("loginToken"); 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) { if (!token) {
return; return;
} }

View File

@@ -151,7 +151,8 @@ const runtime = {
refreshTokens: [], refreshTokens: [],
tokenVersionByUser: {}, tokenVersionByUser: {},
emailTokens: [], emailTokens: [],
otpChallenges: [] otpChallenges: [],
oauthChallenges: []
}, },
approvalRequests: [], approvalRequests: [],
systemState: { systemState: {
@@ -345,6 +346,10 @@ async function routeRequest(req, res) {
return handleLogoutAll(res, auth.user); 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") { if (method === "GET" && url.pathname === "/v1/me") {
const auth = requireAuth(req, res); const auth = requireAuth(req, res);
if (!auth) return; if (!auth) return;
@@ -1042,6 +1047,51 @@ async function handleRequestAccess(req, res, body) {
html: message.html, html: message.html,
code: otpCode 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 { } else {
return sendError(res, 400, "auth.method_invalid", "Unbekannte Bestaetigungsart"); 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"); return sendError(res, 404, "auth.user_not_found", "Benutzer nicht gefunden");
} }
if (verification.purpose === "verify") { const result = await finalizeLoginForVerification(req, user, verification.purpose, verification.purpose);
user.emailVerifiedAt = new Date().toISOString(); if (result && result.error) {
if (user.accountType === "primary-domain" || user.role === "admin") { return sendError(res, result.error.status, result.error.code, result.error.message);
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."
});
} }
return sendJson(res, 200, result);
} }
if (runtime.systemState.maintenanceMode && user.role !== "admin") { async function handleOauthCallback(req, res, url) {
return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage); 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 (user.status !== "active") { if (!stateToken || !code) {
return sendError(res, 403, "auth.not_approved", "Benutzer ist noch nicht freigegeben"); return redirectToLoginWithAuthError(res, "auth.oauth_missing", "OAuth Parameter fehlen");
} }
const tokens = await issueTokenPair(user, req); const challenge = consumeOauthChallenge(stateToken);
await appendAudit("auth.login", user, { sid: tokens.sid, via: verification.purpose }); if (!challenge.ok) {
return sendJson(res, 200, { return redirectToLoginWithAuthError(res, "auth.oauth_invalid", challenge.message || "OAuth state ungueltig");
ok: true, }
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), user: sanitizeUser(user),
accessToken: tokens.accessToken, recipient: user.email,
refreshToken: tokens.refreshToken, origin: publicBaseUrlFor(req),
expiresInSec: config.accessTokenTtlSec 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");
}
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");
}
} }
async function handleRequestApproval(req, res, body) { async function handleRequestApproval(req, res, body) {
@@ -1925,6 +2018,116 @@ function resolveAuthMethodForUser(user, requestedMethodId) {
return allowed[0] || null; 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) { async function dispatchAuthChallenge(req, user, method, payload) {
const plugin = runtime.plugins.get(method.pluginId); const plugin = runtime.plugins.get(method.pluginId);
if (!plugin || typeof plugin.instance.execute !== "function") { if (!plugin || typeof plugin.instance.execute !== "function") {
@@ -6445,7 +6648,9 @@ async function loadPlugins() {
for (const [id, plugin] of runtime.plugins.entries()) { for (const [id, plugin] of runtime.plugins.entries()) {
if (!(id in runtime.pluginState.enabled)) { 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) { if (runtime.pluginState.enabled[id] && plugin.instance.start) {
await plugin.instance.start(); await plugin.instance.start();
@@ -7040,6 +7245,10 @@ async function applyAdminRoles() {
runtime.authState.otpChallenges = []; runtime.authState.otpChallenges = [];
changed = true; changed = true;
} }
if (!Array.isArray(runtime.authState.oauthChallenges)) {
runtime.authState.oauthChallenges = [];
changed = true;
}
const availableMethods = listPublicAuthMethods(); const availableMethods = listPublicAuthMethods();
const availableMethodIds = availableMethods.map((entry) => entry.id); const availableMethodIds = availableMethods.map((entry) => entry.id);
const defaultMethodId = preferredAuthMethodId(availableMethods); const defaultMethodId = preferredAuthMethodId(availableMethods);