From ee0ea3ef6e2e1eae2680e8e854b8581fb7e1d7f1 Mon Sep 17 00:00:00 2001 From: jtenniswood Date: Wed, 2 Sep 2026 11:34:47 +0100 Subject: [PATCH] feat(studio): external-mode OIDC login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-in for external-mode daemons behind an OIDC-aware gateway. lib/oidc carries PKCE (+ the RFC 7636 test vector), the in-memory token store, and resolve-authorization — whose ordering suite pins the contract that matters for this series: OIDC unconfigured ⇒ the static-bearer path is byte- identical, so every earlier PR's behavior is provably unchanged. lib/oidc-session.ts adds the discovery cache, PKCE attempt TTL/cap, single-flight refresh, and RFC 7009 sign-out. The auth routes (status/start/callback/logout) sit behind requestIsTrusted with an escaped postMessage close page; the settings login card renders in external mode only. server-proxy.ts takes its first allowlisted re-touch: the inline static-token block becomes resolveExternalAuthorization(). .env.example gains the five MECATL_OIDC_* rows — fixing a documented gap in the original branch, where the vars existed in code but not in the example file. Co-Authored-By: Claude Fable 5 --- docs/design/PRODUCTION-READINESS.md | 2 +- studio/.env.example | 10 + .../src/app/api/auth/oidc/[action]/route.ts | 102 ++++ .../settings/_components/oidc-login-card.tsx | 150 ++++++ .../app/workspace/settings/provider/page.tsx | 2 + studio/src/lib/oidc-session.ts | 452 ++++++++++++++++++ studio/src/lib/oidc/pkce.test.ts | 171 +++++++ studio/src/lib/oidc/pkce.ts | 129 +++++ .../lib/oidc/resolve-authorization.test.ts | 82 ++++ studio/src/lib/oidc/resolve-authorization.ts | 66 +++ studio/src/lib/oidc/token-store.test.ts | 225 +++++++++ studio/src/lib/oidc/token-store.ts | 218 +++++++++ studio/src/lib/request-trust.test.ts | 12 +- studio/src/lib/server-proxy.ts | 21 +- user-docs/building/what-you-get/studio.md | 12 +- 15 files changed, 1640 insertions(+), 14 deletions(-) create mode 100644 studio/src/app/api/auth/oidc/[action]/route.ts create mode 100644 studio/src/app/workspace/settings/_components/oidc-login-card.tsx create mode 100644 studio/src/lib/oidc-session.ts create mode 100644 studio/src/lib/oidc/pkce.test.ts create mode 100644 studio/src/lib/oidc/pkce.ts create mode 100644 studio/src/lib/oidc/resolve-authorization.test.ts create mode 100644 studio/src/lib/oidc/resolve-authorization.ts create mode 100644 studio/src/lib/oidc/token-store.test.ts create mode 100644 studio/src/lib/oidc/token-store.ts diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index ba655dcc63..75fdd70116 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do | mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) | | ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) | | Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) | -| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ⛔ external-mode OIDC sign-in · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | +| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ✅ external-mode OIDC sign-in (PKCE, server-held tokens) · ⛔ advanced chat tiers (attachments, tool drill-down, steer/queue, threads, re-attach, modes, mobile) · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | | _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — | ## Security diff --git a/studio/.env.example b/studio/.env.example index c403659c3d..cf5c5cfec3 100644 --- a/studio/.env.example +++ b/studio/.env.example @@ -44,6 +44,16 @@ # required). # MECATL_ALLOW_INSECURE_LOOPBACK_MCP= +# ── External-mode OIDC sign-in (optional) ──────────────────────────────────── +# When the external daemon sits behind an OIDC-aware gateway, Studio's server +# tier can run the sign-in itself (PKCE; tokens held in server process memory +# only). Unset = the static MECATL_AUTH_TOKEN path above. +# MECATL_OIDC_ISSUER= +# MECATL_OIDC_CLIENT_ID= +# MECATL_OIDC_REDIRECT_URI= +# MECATL_OIDC_SCOPE= +# MECATL_OIDC_AUDIENCE= + # ── Branding (optional) ────────────────────────────────────────────────────── # Display name used as the logo's alt text. # BRAND_NAME= diff --git a/studio/src/app/api/auth/oidc/[action]/route.ts b/studio/src/app/api/auth/oidc/[action]/route.ts new file mode 100644 index 0000000000..55fe5c8905 --- /dev/null +++ b/studio/src/app/api/auth/oidc/[action]/route.ts @@ -0,0 +1,102 @@ +/** + * Remote OIDC login routes (requirement H3) — the server-tier half of + * Authorization Code + PKCE against an OIDC-protected external mecated. + * Tokens live in `src/lib/oidc-session.ts` (process memory) and never reach + * the browser (rule 3). Same origin-trust table as the proxy routes (rule 4's + * browser-facing half). + * + * GET /api/auth/oidc/status → sign-in state for the settings card + * GET /api/auth/oidc/start → 302 to the issuer's authorize URL + * (opened by the card's Sign in button — + * never without that user action, H3.3) + * GET /api/auth/oidc/callback → the registered redirect URI; verifies + * state + PKCE server-side, exchanges the + * code, renders a close-this-tab page + * POST /api/auth/oidc/logout → local sign-out + best-effort revocation + */ +import { + beginOidcLogin, + type CallbackOutcome, + completeOidcCallback, + oidcLoginStatus, + oidcSignOut, +} from "@/lib/oidc-session"; +import { requestIsTrusted } from "@/lib/request-trust"; + +type Context = { params: Promise<{ action: string }> }; + +const noStore = { "cache-control": "no-store" }; + +const forbidden = () => + Response.json({ error: "request origin is not allowed" }, { status: 403 }); + +const unknownAction = () => + Response.json({ error: "unknown auth action" }, { status: 404 }); + +export async function GET(request: Request, context: Context) { + if (!requestIsTrusted(request)) return forbidden(); + const { action } = await context.params; + if (action === "status") { + return Response.json(oidcLoginStatus(), { headers: noStore }); + } + if (action === "start") { + try { + const { authorizationUrl } = await beginOidcLogin(); + return new Response(null, { + status: 302, + headers: { ...noStore, location: authorizationUrl }, + }); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : "Could not start OIDC sign-in."; + return Response.json( + { error: message }, + { status: 400, headers: noStore }, + ); + } + } + if (action === "callback") { + const outcome = await completeOidcCallback( + new URL(request.url).searchParams, + ); + return callbackPage(outcome); + } + return unknownAction(); +} + +export async function POST(request: Request, context: Context) { + if (!requestIsTrusted(request)) return forbidden(); + const { action } = await context.params; + if (action === "logout") { + return Response.json(await oidcSignOut(), { headers: noStore }); + } + return unknownAction(); +} + +/** Mirrors the controller's gateway-OAuth callback page: a tiny same-origin + * HTML page that notifies the opener (the settings card listens for the + * `mecatl-oidc` message) and asks to be closed. The failure message is + * harness-authored or provider-bounded upstream, and escaped here anyway. */ +function callbackPage(outcome: CallbackOutcome): Response { + const headers = { + "content-type": "text/html; charset=utf-8", + // The authorization code rides this page's URL; keep it out of referrers. + "referrer-policy": "no-referrer", + ...noStore, + }; + const notify = (ok: boolean) => + ``; + if (outcome.ok) { + return new Response( + `Signed in to Mecatl

Signed in

You can close this tab and return to Mecatl Studio.

${notify(true)}`, + { headers }, + ); + } + const message = outcome.error.replace(/[<>&"']/g, ""); + return new Response( + `Mecatl sign-in failed

Could not sign in

${message}

${notify(false)}`, + { status: 400, headers }, + ); +} diff --git a/studio/src/app/workspace/settings/_components/oidc-login-card.tsx b/studio/src/app/workspace/settings/_components/oidc-login-card.tsx new file mode 100644 index 0000000000..b9530e1578 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/oidc-login-card.tsx @@ -0,0 +1,150 @@ +"use client"; + +/** + * Remote sign-in card for OIDC-protected external deployments (requirement + * H3). Self-contained and NOT yet mounted anywhere — the natural home is the + * Settings runtime/connection section (beside the connection status), mounted + * only when the deployment mode is external; the orchestrator wires it in + * during integration. + * + * Talks only to the server-tier auth routes (`/api/auth/oidc/*`); no token + * ever reaches this component (rule 3). Sign in opens the authorize redirect + * in a popup — the explicit user action H3.3 requires — and the card refreshes + * on the callback page's `mecatl-oidc` message and on window focus. + */ +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Note, SettingsCard, SettingsRow } from "./settings-card"; + +type OidcStatus = { + configured: boolean; + state: "not-configured" | "signed-out" | "signed-in" | "expired"; + problem?: string; + issuer?: string; + subject?: string; + email?: string; + expiresAt?: string; +}; + +export function OidcLoginCard() { + const [status, setStatus] = useState(null); + const [failed, setFailed] = useState(false); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const response = await fetch("/api/auth/oidc/status", { + cache: "no-store", + }); + if (!response.ok) throw new Error(); + setStatus((await response.json()) as OidcStatus); + setFailed(false); + } catch { + setStatus(null); + setFailed(true); + } + }, []); + + useEffect(() => { + void refresh(); + const onMessage = (event: MessageEvent) => { + const data = event.data as { type?: unknown } | null; + if ( + event.origin === window.location.origin && + data?.type === "mecatl-oidc" + ) { + void refresh(); + } + }; + const onFocus = () => void refresh(); + window.addEventListener("message", onMessage); + window.addEventListener("focus", onFocus); + return () => { + window.removeEventListener("message", onMessage); + window.removeEventListener("focus", onFocus); + }; + }, [refresh]); + + const signIn = () => { + // The route 302s straight to the issuer's authorize URL; the callback + // page notifies this card and closes itself. + window.open( + "/api/auth/oidc/start", + "mecatl-oidc-login", + "width=520,height=680", + ); + }; + + const signOut = async () => { + setBusy(true); + try { + await fetch("/api/auth/oidc/logout", { method: "POST" }); + } catch { + // Local sign-out state is authoritative server-side; refresh shows it. + } finally { + setBusy(false); + void refresh(); + } + }; + + return ( + + {failed ? ( + The sign-in status could not be read right now. + ) : !status ? ( + Checking sign-in status… + ) : status.state === "not-configured" ? ( + + {status.problem || + "Not configured. Set MECATL_OIDC_ISSUER and MECATL_OIDC_CLIENT_ID (and optionally MECATL_OIDC_AUDIENCE) in Studio's environment to sign in to an OIDC-protected deployment."} + + ) : ( +
+ {status.state === "signed-in" ? ( + + + + ) : ( + + + + )} +
+ )} +
+ ); +} diff --git a/studio/src/app/workspace/settings/provider/page.tsx b/studio/src/app/workspace/settings/provider/page.tsx index f4a875627d..2f86e474ee 100644 --- a/studio/src/app/workspace/settings/provider/page.tsx +++ b/studio/src/app/workspace/settings/provider/page.tsx @@ -3,6 +3,7 @@ import { useHarnessRuntime } from "@/features/agent/hooks/use-harness-runtime"; import { useProviderManagement } from "@/features/agent/hooks/use-provider-management"; import { AboutDaemonCard } from "../_components/about-daemon-card"; +import { OidcLoginCard } from "../_components/oidc-login-card"; import { ProviderSection } from "../_components/provider-section"; import { RuntimeStatusLine } from "../_components/runtime-status-line"; @@ -18,6 +19,7 @@ export default function ProviderSettingsPage() { /> {/* Remote-daemon login (H3): only external mode authenticates upstream, and the card itself explains a half-configured issuer. */} + {runtime.mode === "external" && } ); } diff --git a/studio/src/lib/oidc-session.ts b/studio/src/lib/oidc-session.ts new file mode 100644 index 0000000000..970e493ae9 --- /dev/null +++ b/studio/src/lib/oidc-session.ts @@ -0,0 +1,452 @@ +import "server-only"; + +/** + * Remote OIDC login for external mode (requirement H3, #847; the client-side + * pattern is mecatui's ADR 0277): Authorization Code + PKCE runs ENTIRELY in + * the Next server tier — this module owns issuer discovery, the pending login + * attempts, the in-memory token store, refresh, and revocation. Tokens never + * reach the browser (rule 3) and are never written to disk: a Next server + * restart requires one fresh sign-in (v1 deliberately defers durable storage; + * ADR 0277's keyring-wrapped store is the eventual model). + * + * Env contract (all three unset → the static MECATL_AUTH_TOKEN path, + * byte-identical to pre-H3 behavior): + * + * - MECATL_OIDC_ISSUER — HTTPS issuer URL (required to enable) + * - MECATL_OIDC_CLIENT_ID — public client id (required to enable) + * - MECATL_OIDC_AUDIENCE — optional; forwarded as the `audience` + * authorization parameter for providers that mint + * audience-bound access tokens + * - MECATL_OIDC_SCOPE — optional; default + * "openid profile email offline_access" + * - MECATL_OIDC_REDIRECT_URI — optional; default = the first + * MECATL_STUDIO_PUBLIC_ORIGIN + + * "/api/auth/oidc/callback" (must be registered + * with the provider) + */ +import { + boundProviderError, + buildAuthorizationUrl, + evaluateCallback, + pkceChallengeS256, + randomUrlSafe, + readCallbackParams, +} from "@/lib/oidc/pkce"; +import { + type ProxyAuthDecision, + resolveProxyAuthorization, +} from "@/lib/oidc/resolve-authorization"; +import { + claimsFromIdToken, + isInvalidGrantResponse, + OidcTokenStore, + type RefreshResult, + type StoredTokens, + tokenExpiryMs, + tokensFromRefreshResponse, +} from "@/lib/oidc/token-store"; +import { studioAllowedOrigins } from "@/lib/request-trust"; + +type OidcEnvConfig = { + issuer: string; + clientId: string; + audience: string; + scope: string; + redirectUri: string; +}; + +function defaultRedirectUri(): string { + const first = [...studioAllowedOrigins()][0] || "http://localhost:3000"; + return `${first}/api/auth/oidc/callback`; +} + +function oidcConfig(): OidcEnvConfig | null { + const issuer = process.env.MECATL_OIDC_ISSUER?.trim().replace(/\/+$/, ""); + const clientId = process.env.MECATL_OIDC_CLIENT_ID?.trim(); + if (!issuer || !clientId) return null; + return { + issuer, + clientId, + audience: process.env.MECATL_OIDC_AUDIENCE?.trim() || "", + scope: + process.env.MECATL_OIDC_SCOPE?.trim() || + "openid profile email offline_access", + redirectUri: + process.env.MECATL_OIDC_REDIRECT_URI?.trim() || defaultRedirectUri(), + }; +} + +/** A half-set configuration is surfaced (never silently ignored) — but the + * proxy stays on the static path so a typo cannot brick every request. */ +function configProblem(): string { + const issuer = process.env.MECATL_OIDC_ISSUER?.trim(); + const clientId = process.env.MECATL_OIDC_CLIENT_ID?.trim(); + if (issuer && !clientId) + return "MECATL_OIDC_ISSUER is set but MECATL_OIDC_CLIENT_ID is missing."; + if (clientId && !issuer) + return "MECATL_OIDC_CLIENT_ID is set but MECATL_OIDC_ISSUER is missing."; + return ""; +} + +type Discovery = { + authorizationEndpoint: string; + tokenEndpoint: string; + /** "" when the provider does not advertise RFC 7009 revocation. */ + revocationEndpoint: string; + issParameterSupported: boolean; +}; + +type LoginAttempt = { verifier: string; expires: number }; + +/** Stashed on globalThis so Next dev's module reloads don't sign the + * operator out mid-session. Process memory only — see the module comment. */ +type OidcRuntime = { + store: OidcTokenStore; + attempts: Map; + discovery: { issuer: string; doc: Discovery } | null; +}; + +const globalStash = globalThis as typeof globalThis & { + __mecatlOidcRuntime?: OidcRuntime; +}; +if (!globalStash.__mecatlOidcRuntime) { + globalStash.__mecatlOidcRuntime = { + store: new OidcTokenStore(), + attempts: new Map(), + discovery: null, + }; +} +const runtime: OidcRuntime = globalStash.__mecatlOidcRuntime; + +const ATTEMPT_TTL_MS = 10 * 60_000; +const MAX_PENDING_ATTEMPTS = 32; + +function pruneAttempts(now = Date.now()): void { + for (const [state, attempt] of runtime.attempts) { + if (attempt.expires < now) runtime.attempts.delete(state); + } + // Map iteration is insertion-ordered, so overflow drops the oldest. + while (runtime.attempts.size >= MAX_PENDING_ATTEMPTS) { + const oldest = runtime.attempts.keys().next().value; + if (oldest === undefined) break; + runtime.attempts.delete(oldest); + } +} + +function requireHttpsEndpoint(value: unknown, label: string): string { + if (typeof value !== "string" || !value) + throw new Error(`OIDC discovery does not include ${label}`); + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + // Harness-authored message only — never echo the provider's value. + throw new Error(`OIDC ${label} is not a valid URL`); + } + if (endpoint.protocol !== "https:") + throw new Error(`OIDC ${label} must use HTTPS`); + return endpoint.toString(); +} + +async function fetchDiscovery( + issuer: string, + signal?: AbortSignal, +): Promise { + if (runtime.discovery?.issuer === issuer) return runtime.discovery.doc; + const wellKnown = requireHttpsEndpoint( + `${issuer}/.well-known/openid-configuration`, + "issuer", + ); + const response = await fetch(wellKnown, { + headers: { accept: "application/json" }, + cache: "no-store", + redirect: "manual", + signal: signal ?? AbortSignal.timeout(8_000), + }); + if (!response.ok) + throw new Error(`OIDC discovery failed (HTTP ${response.status})`); + const body = (await response.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!body) throw new Error("OIDC discovery returned malformed JSON"); + const documentIssuer = + typeof body.issuer === "string" ? body.issuer.replace(/\/+$/, "") : ""; + if (documentIssuer !== issuer) + throw new Error("OIDC discovery issuer does not match MECATL_OIDC_ISSUER"); + const doc: Discovery = { + authorizationEndpoint: requireHttpsEndpoint( + body.authorization_endpoint, + "authorization endpoint", + ), + tokenEndpoint: requireHttpsEndpoint(body.token_endpoint, "token endpoint"), + revocationEndpoint: + typeof body.revocation_endpoint === "string" && body.revocation_endpoint + ? requireHttpsEndpoint(body.revocation_endpoint, "revocation endpoint") + : "", + issParameterSupported: + body.authorization_response_iss_parameter_supported === true, + }; + runtime.discovery = { issuer, doc }; + return doc; +} + +/** Begin one Authorization Code + PKCE attempt. Called ONLY from the login + * route the UI's Sign in button opens — no browser window ever opens without + * that explicit user action (H3.3). */ +export async function beginOidcLogin(): Promise<{ authorizationUrl: string }> { + const cfg = oidcConfig(); + if (!cfg) + throw new Error( + configProblem() || + "OIDC is not configured — set MECATL_OIDC_ISSUER and MECATL_OIDC_CLIENT_ID.", + ); + const doc = await fetchDiscovery(cfg.issuer); + const state = randomUrlSafe(24); + const verifier = randomUrlSafe(48); + pruneAttempts(); + runtime.attempts.set(state, { + verifier, + expires: Date.now() + ATTEMPT_TTL_MS, + }); + return { + authorizationUrl: buildAuthorizationUrl({ + authorizationEndpoint: doc.authorizationEndpoint, + clientId: cfg.clientId, + redirectUri: cfg.redirectUri, + scope: cfg.scope, + state, + codeChallenge: pkceChallengeS256(verifier), + audience: cfg.audience || undefined, + }), + }; +} + +export type CallbackOutcome = { ok: true } | { ok: false; error: string }; + +export async function completeOidcCallback( + search: URLSearchParams, +): Promise { + const cfg = oidcConfig(); + if (!cfg) return { ok: false, error: "OIDC is not configured." }; + const params = readCallbackParams(search); + const attempt = params.state ? runtime.attempts.get(params.state) : undefined; + if (params.state) runtime.attempts.delete(params.state); + if (!attempt || attempt.expires < Date.now()) { + return { + ok: false, + error: + "This sign-in attempt is unknown or has expired. Start again from Settings.", + }; + } + let doc: Discovery; + try { + doc = await fetchDiscovery(cfg.issuer); + } catch (error) { + return { ok: false, error: safeMessage(error) }; + } + const verdict = evaluateCallback(params, { + expectedIssuer: cfg.issuer, + issRequired: doc.issParameterSupported, + }); + if (!verdict.ok) return { ok: false, error: verdict.reason }; + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: verdict.code, + client_id: cfg.clientId, + redirect_uri: cfg.redirectUri, + code_verifier: attempt.verifier, + }); + let response: Response; + try { + response = await fetch(doc.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + cache: "no-store", + signal: AbortSignal.timeout(15_000), + }); + } catch { + return { + ok: false, + error: "Could not reach the identity provider's token endpoint.", + }; + } + const result = (await response.json().catch(() => ({}))) as Record< + string, + unknown + >; + const accessToken = + typeof result.access_token === "string" ? result.access_token : ""; + if (!response.ok || !accessToken) { + // Never echo a token-endpoint body — the bounded machine `error` code is + // the only fragment allowed out (ADR 0277's no-provider-bodies rule). + const code = + typeof result.error === "string" ? boundProviderError(result.error) : ""; + return { + ok: false, + error: code + ? `The token exchange failed (${code}).` + : "The token exchange failed.", + }; + } + const now = Date.now(); + runtime.store.setTokens({ + accessToken, + refreshToken: + typeof result.refresh_token === "string" ? result.refresh_token : "", + expiresAt: tokenExpiryMs(result.expires_in, now), + claims: + (typeof result.id_token === "string" && + claimsFromIdToken(result.id_token)) || + {}, + }); + return { ok: true }; +} + +async function refreshExchange( + cfg: OidcEnvConfig, + prior: StoredTokens, +): Promise { + const doc = await fetchDiscovery(cfg.issuer); // a throw is caught as transient + // No `scope` on refresh: RFC 6749 section 6 makes it optional (the server + // reuses the granted scopes) and some providers reject it as malformed — + // the bug that silently killed the MCP gateway's auto-refresh once already + // (see scripts/local-controller.mjs refreshGatewayAccessToken). + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: prior.refreshToken, + client_id: cfg.clientId, + }); + const response = await fetch(doc.tokenEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + cache: "no-store", + signal: AbortSignal.timeout(10_000), + }); + const result = (await response.json().catch(() => ({}))) as Record< + string, + unknown + >; + if (!response.ok) { + return isInvalidGrantResponse(response.status, result) + ? { kind: "invalid-grant" } + : { kind: "transient" }; + } + const tokens = tokensFromRefreshResponse(result, prior, Date.now()); + return tokens ? { kind: "refreshed", tokens } : { kind: "transient" }; +} + +/** + * The proxy's external-mode Authorization decision (H3.2): the current OIDC + * access token when OIDC is configured (refreshed on demand inside the + * 30-second refresh-ahead window), the static MECATL_AUTH_TOKEN when it is + * not, and an honest 401/502 decision the proxy relays otherwise. + */ +export async function resolveExternalAuthorization(): Promise { + const cfg = oidcConfig(); + const staticToken = process.env.MECATL_AUTH_TOKEN; + if (!cfg) { + return resolveProxyAuthorization({ + oidcConfigured: false, + oidc: null, + staticToken, + }); + } + const outcome = await runtime.store.bearer((prior) => + refreshExchange(cfg, prior), + ); + return resolveProxyAuthorization({ + oidcConfigured: true, + oidc: outcome, + staticToken, + }); +} + +export type OidcLoginStatus = { + configured: boolean; + state: "not-configured" | "signed-out" | "signed-in" | "expired"; + /** Set when the env configuration is half-complete. */ + problem?: string; + issuer?: string; + subject?: string; + email?: string; + /** ISO timestamp of the current access token's expiry (display only). */ + expiresAt?: string; +}; + +/** Status for the settings card. Never includes token material. */ +export function oidcLoginStatus(): OidcLoginStatus { + const cfg = oidcConfig(); + if (!cfg) { + const problem = configProblem(); + return { + configured: false, + state: "not-configured", + ...(problem ? { problem } : {}), + }; + } + const snapshot = runtime.store.snapshot(); + if (snapshot.state !== "signed-in") { + return { configured: true, state: snapshot.state, issuer: cfg.issuer }; + } + return { + configured: true, + state: "signed-in", + issuer: cfg.issuer, + subject: snapshot.claims.preferredUsername || snapshot.claims.sub, + email: snapshot.claims.email, + expiresAt: new Date(snapshot.expiresAt).toISOString(), + }; +} + +const REVOCATION_BUDGET_MS = 5_000; + +/** + * Sign out: local credential removal FIRST, then bounded best-effort RFC 7009 + * revocation under one operation-wide budget (ADR 0277's logout shape; the + * 5-second budget mirrors ADR 0274). Provider failure never restores local + * state, and repeating logout while signed out succeeds. + */ +export async function oidcSignOut(): Promise<{ ok: true; revoked: boolean }> { + const cfg = oidcConfig(); + const tokens = runtime.store.current(); + runtime.store.clear("signed-out"); + if (!cfg || !tokens) return { ok: true, revoked: false }; + const budget = AbortSignal.timeout(REVOCATION_BUDGET_MS); + let revoked = false; + try { + const doc = await fetchDiscovery(cfg.issuer, budget); + if (doc.revocationEndpoint) { + const revoke = async (token: string, hint: string) => { + if (!token) return true; + const response = await fetch(doc.revocationEndpoint, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + token, + token_type_hint: hint, + client_id: cfg.clientId, + }), + cache: "no-store", + signal: budget, + }); + return response.ok; + }; + const refreshRevoked = await revoke(tokens.refreshToken, "refresh_token"); + const accessRevoked = await revoke(tokens.accessToken, "access_token"); + revoked = refreshRevoked && accessRevoked; + } + } catch { + // Best-effort only — local sign-out already happened. + } + return { ok: true, revoked }; +} + +function safeMessage(error: unknown): string { + return error instanceof Error && error.message + ? error.message + : "OIDC sign-in failed."; +} diff --git a/studio/src/lib/oidc/pkce.test.ts b/studio/src/lib/oidc/pkce.test.ts new file mode 100644 index 0000000000..4a9e6fcd85 --- /dev/null +++ b/studio/src/lib/oidc/pkce.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { + boundProviderError, + buildAuthorizationUrl, + evaluateCallback, + pkceChallengeS256, + randomUrlSafe, + readCallbackParams, +} from "./pkce"; + +describe("randomUrlSafe", () => { + it("emits URL-safe values with no padding", () => { + for (let i = 0; i < 20; i++) { + expect(randomUrlSafe(32)).toMatch(/^[A-Za-z0-9_-]{43}$/); + } + }); + + it("does not repeat", () => { + const seen = new Set(Array.from({ length: 100 }, () => randomUrlSafe(32))); + expect(seen.size).toBe(100); + }); +}); + +describe("pkceChallengeS256", () => { + it("matches the RFC 7636 appendix B vector", () => { + expect( + pkceChallengeS256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + ).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + }); +}); + +describe("buildAuthorizationUrl", () => { + const base = { + authorizationEndpoint: "https://issuer.example/authorize", + clientId: "studio", + redirectUri: "http://localhost:3000/api/auth/oidc/callback", + scope: "openid profile email offline_access", + state: "the-state", + codeChallenge: "the-challenge", + }; + + it("carries the full Authorization Code + PKCE parameter set", () => { + const url = new URL(buildAuthorizationUrl(base)); + expect(url.origin + url.pathname).toBe("https://issuer.example/authorize"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe("studio"); + expect(url.searchParams.get("redirect_uri")).toBe(base.redirectUri); + expect(url.searchParams.get("scope")).toBe(base.scope); + expect(url.searchParams.get("state")).toBe("the-state"); + expect(url.searchParams.get("code_challenge")).toBe("the-challenge"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("audience")).toBeNull(); + }); + + it("includes audience only when configured", () => { + const url = new URL( + buildAuthorizationUrl({ ...base, audience: "https://mecated.example" }), + ); + expect(url.searchParams.get("audience")).toBe("https://mecated.example"); + }); +}); + +describe("boundProviderError", () => { + it("restricts to the RFC 6749 printable subset and clamps", () => { + expect(boundProviderError('bad "quote" \\ and\nnewline\tand ünïcode')).toBe( + "bad quote andnewlineand ncode", + ); + expect(boundProviderError("x".repeat(500)).length).toBe(200); + }); + + it("strips a script payload's angle-bracket-free remainder harmlessly", () => { + // The characters survive (printable subset), but the callback page's own + // HTML escaping is the render-time defense; this bound is transport-side. + expect(boundProviderError("access_denied")).toBe("access_denied"); + }); +}); + +describe("evaluateCallback", () => { + const check = { + expectedIssuer: "https://issuer.example", + issRequired: false, + }; + + it("accepts a plain code when iss is not advertised", () => { + expect( + evaluateCallback( + { code: "c0de", error: "", errorDescription: "", iss: "" }, + check, + ), + ).toEqual({ + ok: true, + code: "c0de", + }); + }); + + it("returns the bounded provider error", () => { + const verdict = evaluateCallback( + { + code: "", + error: 'access_denied"