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