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.
213 lines
7.0 KiB
JavaScript
213 lines
7.0 KiB
JavaScript
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
|
|
};
|