Files
ARCG-Remote-Station-Software/plugins/rms.auth.oauth/index.js
OE6DXD 2b05057aa2 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.
2026-03-16 12:57:30 +01:00

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