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

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