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 MecatlSigned 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 failedCould 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"