"use client"; /* Auth Providers - master/detail: left list, right Strategy + Credentials forms + masked test. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Icon } from "../icons"; import { Field, Modal, StatusPill, Tile, Toggle } from "../primitives"; import { VersionHistory } from "../version-history"; import { api, AuthProviderT, Tool } from "@/lib/api"; const KIND_LABEL: Record = { csrf_session: "CSRF + session", oauth2_client_credentials: "OAuth2 client-creds", oauth2_authorization_code: "OAuth2 (user login)", bearer: "Bearer token", basic: "Basic auth", api_key: "API key", custom_script: "Custom script", }; // One-line "use this when…" per strategy - shown in the create picker so the choice is legible. const KIND_DESC: Record = { bearer: "A static token sent in a header. The simplest option.", api_key: "A key sent as a header or query param.", basic: "Username + password (HTTP Basic).", oauth2_client_credentials: "Machine-to-machine - Forge trades a client id/secret for a short-lived token.", oauth2_authorization_code: "A user signs in on the provider's consent page; tokens auto-refresh.", csrf_session: "Log in to a web app, capture its CSRF token + session cookie, and replay them. For targets with no real API auth.", }; const TEMPLATES: Record = { bearer: { kind: "bearer", token_ref: "secret://proj/token", header_name: "Authorization", prefix: "Bearer " }, api_key: { kind: "api_key", in: "header", name: "X-API-Key", value_ref: "secret://proj/api_key" }, basic: { kind: "basic", username_ref: "secret://proj/user", password_ref: "secret://proj/pass" }, oauth2_client_credentials: { kind: "oauth2_client_credentials", token_url: "https://idp.example.com/oauth/token", scope: "read", client_id_ref: "secret://proj/client_id", client_secret_ref: "secret://proj/client_secret" }, oauth2_authorization_code: { kind: "oauth2_authorization_code", authorize_url: "https://accounts.example.com/o/oauth2/v2/auth", token_url: "https://oauth2.example.com/token", scope: "openid email", client_id_ref: "secret://proj/client_id", client_secret_ref: "secret://proj/client_secret" }, csrf_session: { kind: "csrf_session", credentials_ref: "secret://proj/creds", token_fetch: { method: "POST", url: "https://app.example.com/login", headers: { "Content-Type": "application/json" }, body: { username: "{{cred.username}}", password: "{{cred.password}}" } }, extract: [{ name: "csrf", from: "header", header: "X-CSRF-Token" }, { name: "session", from: "cookie", cookie: "SESSIONID" }], inject: [{ to: "header", name: "X-CSRF-Token", value: "{{extracted.csrf}}" }, { to: "cookie", name: "SESSIONID", value: "{{extracted.session}}" }], cache_ttl_seconds: 1800, refresh_on: [401, 403], }, }; export function AuthProvidersScreen({ project }: { project: any }) { const [rows, setRows] = useState([]); const [tools, setTools] = useState([]); const [selId, setSelId] = useState(null); const [createOpen, setCreateOpen] = useState(false); const reload = useCallback(() => { if (!project?.id) return; api.listAuthProviders(project.id).then((r) => { setRows(r); setSelId((s) => s && r.some((x) => x.id === s) ? s : (r[0]?.id ?? null)); }).catch(() => {}); api.listTools(project.id).then(setTools).catch(() => {}); }, [project?.id]); useEffect(() => { reload(); }, [reload]); const toolCount = useMemo(() => { const m: Record = {}; tools.forEach((t) => { if (t.auth_provider_id) m[t.auth_provider_id] = (m[t.auth_provider_id] || 0) + 1; }); return m; }, [tools]); const sel = rows.find((r) => r.id === selId) || null; return (
{/* LEFT list */}
Auth Providers
{rows.length === 0 &&
No providers yet. Click + to add one.
} {rows.map((p) => { const on = selId === p.id; return ( ); })}
{/* RIGHT detail */}
{sel ? : (
Select or add a provider
)}
setCreateOpen(false)} onCreate={async (name, kind) => { const cfg = TEMPLATES[kind] || { kind }; const ap = await api.createAuthProvider(project.id, { name: name || kind, kind, config: cfg, credentials_ref: cfg.credentials_ref }); setCreateOpen(false); reload(); setSelId(ap.id); }} />
); } function CreateModal({ open, onClose, onCreate }: { open: boolean; onClose: () => void; onCreate: (name: string, kind: string) => void }) { const [name, setName] = useState(""); const [kind, setKind] = useState("bearer"); return ( }>
{Object.keys(TEMPLATES).map((k) => { const on = kind === k; return ( ); })}
setName(e.target.value)} placeholder="orders_api" />
); } function ProviderDetail({ project, provider, onSaved }: { project: any; provider: AuthProviderT; onSaved: () => void }) { const [cfg, setCfg] = useState(() => ({ ...(provider.config || {}), kind: provider.kind })); const [name, setName] = useState(provider.name); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [test, setTest] = useState(null); const [reveal, setReveal] = useState(false); const kind = cfg.kind; function setPath(path: string[], value: any) { setCfg((c: any) => { const next = structuredClone(c); let o = next; for (let i = 0; i < path.length - 1; i++) { o[path[i]] = o[path[i]] ?? {}; o = o[path[i]]; } o[path[path.length - 1]] = value; return next; }); setSaved(false); } const get = (path: string[], dflt: any = "") => path.reduce((o, k) => (o == null ? o : o[k]), cfg) ?? dflt; // Per-user ("external") auth: bearer/api_key providers can be marked so EACH user supplies their // own token (stored per-user) instead of one shared secret - the mechanism behind per-user MCP + // on-behalf-of calls. Marked by per_user_context_keys containing "end_user_id". const supportsPerUser = kind === "bearer" || kind === "api_key"; const perUser = ((cfg.per_user_context_keys as string[]) || []).includes("end_user_id"); function setPerUser(on: boolean) { setCfg((c: any) => { const next = structuredClone(c); if (on) next.per_user_context_keys = ["end_user_id"]; else delete next.per_user_context_keys; return next; }); setSaved(false); } async function save() { setSaving(true); try { const updated = await api.updateAuthProvider(project.id, provider.id, { name, kind, config: cfg, credentials_ref: cfg.credentials_ref }); setSaved(true); onSaved(); } catch { /* */ } finally { setSaving(false); } } async function runTest() { try { const r = await api.testAuthProvider(project.id, provider.id, {}); setTest(r); // Persist a lightweight pass/fail marker (merged into the last-saved config, not the // in-progress edits) so the list StatusPill reflects the real result - mirrors how // tools store _last_test. Best-effort: the test result still shows regardless. try { await api.updateAuthProvider(project.id, provider.id, { config: { ...(provider.config || {}), _last_test: { ok: !!r?.ok, at: Date.now() } } }); onSaved(); } catch { /* marker is best-effort */ } } catch (e: any) { setTest({ ok: false, error: e?.message || String(e) }); } } // csrf_session uses extract/inject arrays; surface the first CSRF rule for editing. const extract = cfg.extract || []; const csrfRule = extract.find((e: any) => e.from === "header") || extract[0] || {}; const jsonRule = extract.find((e: any) => e.from === "json") || {}; function setExtractField(field: string, value: string) { const ext = [...(cfg.extract || [])]; const idx = ext.findIndex((e: any) => e === csrfRule); if (idx >= 0) ext[idx] = { ...ext[idx], [field]: value }; setPath(["extract"], ext); } return (
{provider.name}
{KIND_LABEL[kind] || kind} · ttl {cfg.cache_ttl_seconds || 1800}s
{test && (
{test.ok ?
Would inject (masked):
{JSON.stringify({ headers: test.headers, cookies: test.cookies, params: test.params }, null, 2)}
:
{test.error}
}
)} {/* Strategy */}
Strategy
{ setName(e.target.value); setSaved(false); }} />
{kind === "csrf_session" && ( <>
setPath(["token_fetch", "url"], e.target.value)} /> setPath(["token_fetch", "method"], e.target.value)} />
setExtractField("header", e.target.value)} /> setExtractField("json_path", e.target.value)} placeholder="data.csrfToken" />
setPath(["cache_ttl_seconds"], Number(e.target.value) || 0)} /> )} {kind === "oauth2_client_credentials" && ( <>
setPath(["token_url"], e.target.value)} /> setPath(["scope"], e.target.value)} />
)} {kind === "oauth2_authorization_code" && ( <> setPath(["authorize_url"], e.target.value)} />
setPath(["token_url"], e.target.value)} /> setPath(["scope"], e.target.value)} />
)} {kind === "api_key" && (
setPath(["name"], e.target.value)} />
)} {kind === "bearer" && (
setPath(["header_name"], e.target.value)} /> setPath(["prefix"], e.target.value)} />
)} {supportsPerUser && ( )}
{/* Credentials */}
Credentials
{perUser ? "per-user (each user connects)" : "from secret store"}
{perUser ? ( ) : ( <> {credentialFields(kind).map((cf) => (
setPath([cf.path], e.target.value)} style={{ flex: 1 }} placeholder="secret://proj/…" />
))}
Secret values live in Settings → Secrets. Reference them as secret://proj/<name>.
)}
{/* Extra headers: fixed headers stamped on EVERY call, alongside the auth header. Values may be a literal or a secret:// ref, so a shared service token stays in the store (not per-tool). */}
Extra headers
literal or secret://
Sent on every call alongside the auth header — use for fixed service / attestation headers (e.g. a client id, a service token). Each value is a literal or a secret://proj/<name> ref, so a shared secret stays in Settings → Secrets instead of hardcoded per tool.
setPath(["extra_headers"], v)} />
); } /* Rows editor for a provider's extra_headers (name -> value). Values are literals or secret:// refs; the resolver stamps them on every call and resolves any secret ref from the store. */ function ExtraHeadersEditor({ value, onChange }: { value: Record; onChange: (v: Record) => void }) { const rows = Object.entries(value || {}); const rebuild = (next: [string, string][]) => onChange(Object.fromEntries(next.filter(([k]) => k.trim()))); return (
{rows.map(([k, v], i) => (
rebuild(rows.map((r, idx): [string, string] => (idx === i ? [e.target.value, r[1]] : r)))} /> rebuild(rows.map((r, idx): [string, string] => (idx === i ? [r[0], e.target.value] : r)))} />
))}
); } function OAuthConnect({ project, provider }: { project: any; provider: AuthProviderT }) { const [status, setStatus] = useState<{ connected: boolean; scope?: string | null } | null>(null); const [err, setErr] = useState(null); const refresh = useCallback(() => { api.oauthStatus(project.id, provider.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, provider.id]); useEffect(() => { refresh(); }, [refresh]); async function connect() { setErr(null); try { const { authorize_url } = await api.oauthStart(project.id, provider.id); const w = window.open(authorize_url, "_blank", "width=620,height=760"); // Poll status a few times after the popup so the badge flips to "connected". const t = setInterval(() => refresh(), 2500); setTimeout(() => { clearInterval(t); try { w?.close(); } catch { /* ignore */ } }, 60000); } catch { setErr("Could not start OAuth - save the provider first and set the client_id secret in Settings → Secrets."); } } return (
User authorization {status?.connected ? connected : not connected}
{status?.scope &&
scope: {status.scope}
} {err &&
{err}
}
Save the provider, set the client_id/secret secrets, then Connect. A popup completes the grant; tokens auto-refresh.
); } /* Per-user credential: the CURRENT user's own downstream token for a per-user provider. Stored server-side keyed by their user id (same identity the MCP PAT resolves to), so tools act as them without a shared secret. The same box appears on the connector token page. */ function PerUserConnect({ project, provider }: { project: any; provider: AuthProviderT }) { const [status, setStatus] = useState<{ connected: boolean } | null>(null); const [token, setToken] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const refresh = useCallback(() => { api.getMyConnection(project.id, provider.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, provider.id]); useEffect(() => { refresh(); }, [refresh]); async function save() { setErr(null); setBusy(true); try { const res = await api.setMyConnection(project.id, provider.id, token.trim()); if (!res.ok) throw new Error(res.status === 400 ? "Save this provider as per-user first, then set your token." : "Could not save token."); setToken(""); refresh(); } catch (e: any) { setErr(e?.message || String(e)); } finally { setBusy(false); } } async function clear() { setErr(null); try { await api.clearMyConnection(project.id, provider.id); } catch { /* best-effort */ } refresh(); } return (
Your token{status?.connected ? connected : not connected}
{status?.connected && }
setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
{err &&
{err}
}
Stored per-user and encrypted; used only for calls made as you, and never shown again after saving.
); } function credentialFields(kind: string): { path: string; label: string; help?: string }[] { switch (kind) { case "csrf_session": return [{ path: "credentials_ref", label: "Credentials secret ref", help: "Holds { username, password } for the login call." }]; case "bearer": return [{ path: "token_ref", label: "Token secret ref" }]; case "api_key": return [{ path: "value_ref", label: "API key secret ref" }]; case "basic": return [{ path: "username_ref", label: "Username secret ref" }, { path: "password_ref", label: "Password secret ref" }]; case "oauth2_client_credentials": case "oauth2_authorization_code": return [{ path: "client_id_ref", label: "Client ID secret ref" }, { path: "client_secret_ref", label: "Client secret ref" }]; default: return [{ path: "credentials_ref", label: "Credentials secret ref" }]; } }