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:
@@ -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
|
||||||
|
|||||||
212
plugins/rms.auth.oauth/index.js
Normal file
212
plugins/rms.auth.oauth/index.js
Normal 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
|
||||||
|
};
|
||||||
32
plugins/rms.auth.oauth/manifest.json
Normal file
32
plugins/rms.auth.oauth/manifest.json
Normal 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": []
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
283
server/index.js
283
server/index.js
@@ -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();
|
return sendJson(res, 200, result);
|
||||||
user.deniedAt = null;
|
}
|
||||||
await writeJson(files.users, runtime.users);
|
|
||||||
await appendAudit("auth.verified_auto_approved", user, null);
|
async function handleOauthCallback(req, res, url) {
|
||||||
} else {
|
const error = String(url.searchParams.get("error") || "").trim();
|
||||||
user.status = "pending_approval";
|
const errorDescription = String(url.searchParams.get("error_description") || "").trim();
|
||||||
user.deniedAt = null;
|
const stateToken = String(url.searchParams.get("state") || "").trim();
|
||||||
await writeJson(files.users, runtime.users);
|
const code = String(url.searchParams.get("code") || "").trim();
|
||||||
await createApprovalRequest(user, req);
|
|
||||||
await appendAudit("auth.verified_pending_approval", user, null);
|
if (error) {
|
||||||
return sendJson(res, 200, {
|
return redirectToLoginWithAuthError(res, `oauth_${error}`, errorDescription || "OAuth Login fehlgeschlagen");
|
||||||
ok: true,
|
}
|
||||||
verified: true,
|
if (!stateToken || !code) {
|
||||||
approved: false,
|
return redirectToLoginWithAuthError(res, "auth.oauth_missing", "OAuth Parameter fehlen");
|
||||||
message: "E-Mail bestaetigt. Kein direkter Zugriff fuer diese Domain. Freigabe wurde angefordert."
|
}
|
||||||
|
|
||||||
|
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") {
|
const finalPurpose = challenge.purpose === "verify" ? "verifyToken" : "loginToken";
|
||||||
return sendError(res, 503, "auth.maintenance", runtime.systemState.maintenanceMessage);
|
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) {
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user