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:
283
server/index.js
283
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);
|
||||
|
||||
Reference in New Issue
Block a user