diff --git a/.env.example b/.env.example index acf98af..c102632 100644 --- a/.env.example +++ b/.env.example @@ -13,13 +13,17 @@ NEXT_PUBLIC_CLERK_DOMAIN=.clerk.accounts.dev # API Configuration - Only needed to set the Kernel SDK base url API_BASE_URL= +# Public origin of this MCP server. The Managed Auth MCP App connects only to +# the narrowly scoped same-origin relay. Local development: http://localhost:3002 +MANAGED_AUTH_APP_ORIGIN=https://mcp.onkernel.com + # Mintlify API Configuration - Only needed for the search_docs tool call MINTLIFY_ASSISTANT_API_TOKEN=mint_dsc_ MINTLIFY_DOMAIN= # Optional MCP toolset gating. Comma-separated values. # Example: api_keys hides manage_api_keys so deployments can opt out of key management. -# Supported: apps, api_keys, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell +# Supported: apps, api_keys, auth_connections, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell # KERNEL_MCP_DISABLED_TOOLSETS=api_keys # Redis Configuration diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6b331d..acfcb0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,20 @@ jobs: steps: - uses: actions/checkout@v4 + # Pin Bun: the managed-auth App bundle check is byte-exact and Bun's + # minifier output can change between releases. Regenerate the bundle + # with this exact version when bumping it. - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.3" - run: bun install --frozen-lockfile + - name: Check managed-auth App bundle + run: bun run check:managed-auth-app + - name: Type check - run: bunx tsc --noEmit + run: bunx tsc --noEmit --incremental false + + - name: Test + run: bun test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..231db60 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +src/lib/mcp/apps/generated/managed-auth-app.ts diff --git a/README.md b/README.md index 6529328..e27fcbd 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,11 @@ Many other MCP-capable tools accept: Configure these values wherever the tool expects MCP server settings. -## Tools (16 total) +## Tools (17 model-facing, plus 2 app-only helpers) -Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Five standalone tools handle high-frequency workflows. +Each Kernel feature has a single `manage_*` tool with an `action` parameter, keeping the tool set small and consistent. Standalone tools handle high-frequency and interactive workflows. + +Two additional Managed Auth helpers (`begin_auth_login` and `get_auth_login_status`) are marked app-only (`_meta.ui.visibility: ["app"]`); they refuse to execute on hosts that do not declare MCP Apps support. Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_DISABLED_TOOLSETS` to a comma-separated list. For example, `KERNEL_MCP_DISABLED_TOOLSETS=api_keys` prevents `manage_api_keys` from being registered. @@ -272,7 +274,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `manage_replays` - Start, stop, and list MP4 video replay recordings for a browser session. Session-scoped: start once, run your automation, then stop. Requires a paid Kernel plan. - `manage_extensions` - List and delete uploaded browser extensions. - `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results. -- `manage_auth_connections` - Create, list, get, delete managed auth connections; start login flows (returns a hosted URL and live view); submit MFA codes or SSO selections. +- `manage_auth_connections` - List and get sanitized managed-auth connections. Use domain-filtered `list` as the discovery entrypoint; connection mutations stay in the secure App boundary. - `manage_credentials` - Create, list, get, update, and delete stored credentials; fetch a current TOTP code for credentials with a configured totp_secret. - `manage_credential_providers` - Create, list, get, update, and delete external credential providers (e.g. 1Password); list available items and test the provider connection. @@ -283,6 +285,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_ - `execute_playwright_code` - Execute Playwright/TypeScript code against an existing browser session. Does not create or delete browsers - use `manage_browsers` for session lifecycle. - `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr. - `search_docs` - Search Kernel platform documentation and guides. +- `open_auth_login` - Open a secure interactive Managed Auth MCP App after user consent. Credentials and MFA never enter MCP/model traffic. ## Resources @@ -327,6 +330,20 @@ Assistant: I'll create a browser session, then execute Playwright code against i Returns: { success: true, result: "Example Domain" } ``` +### Use managed authentication for a protected site + +1. Call `manage_auth_connections` with `action: "list"` and `domain_filter`. +2. Fetch all pages. If multiple profiles match, ask the user which profile to use. +3. If the selected connection needs auth, explain why and wait for consent before calling `open_auth_login`. +4. Immediately follow the launcher's `next_action`: long-poll `manage_auth_connections` with `action: "wait"`, repeating while it reports `pending`. +5. The user enters credentials/MFA only in the secure MCP App. Never ask for them in chat. +6. When the wait returns `authenticated`, continue the pending task and create the browser with the verified `profile_name`. + +If no App appears, ask the user to confirm that before retrying `open_auth_login` with +`text_only: true`. That compatibility path returns a capability-bearing hosted login URL as +user-audience text. Returning the URL does not mean login succeeded; always verify the +connection afterward. + ### Set up browser profiles for authentication ``` diff --git a/bun.lock b/bun.lock index cb24d63..6953dfc 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", + "@onkernel/managed-auth-react": "0.4.1", "@onkernel/sdk": "^0.78.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -31,6 +32,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.3.14", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/react": "^19", @@ -145,6 +147,8 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.4.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-8p+pMljBRQMKLiBFWbyJQ2ohYflsomWYGgQtlF2sbb4b2w/z+CBsnxUiBs1q23h/W1OtHsbw/jKGX51ZCjNYzA=="], + "@onkernel/sdk": ["@onkernel/sdk@0.78.0", "", {}, "sha512-VrGEDcuSwO6AKe6oYTNaQsAHnOAVeqehmStDTM0EFd3u8+WhITYxhU+jNFq+9yEt0N/nrn2COsk/ThQ+dxWBlw=="], "@redis/bloom": ["@redis/bloom@5.6.0", "", { "peerDependencies": { "@redis/client": "^5.6.0" } }, "sha512-l13/d6BaZDJzogzZJEphIeZ8J0hpQpjkMiozomTm6nJiMNYkoPsNOBOOQua4QsG0fFjyPmLMDJFPAp5FBQtTXg=="], @@ -193,6 +197,8 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], "@types/jszip": ["@types/jszip@3.4.1", "", { "dependencies": { "jszip": "*" } }, "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A=="], @@ -221,6 +227,8 @@ "builtin-modules": ["builtin-modules@5.0.0", "", {}, "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -235,6 +243,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], diff --git a/package.json b/package.json index 46f6178..379c90a 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,12 @@ }, "scripts": { "dev": "next dev -p 3002", - "build": "next build", + "build:managed-auth-app": "bun scripts/build-managed-auth-app.mjs", + "check:managed-auth-app": "bun scripts/build-managed-auth-app.mjs --check", + "build": "bun run build:managed-auth-app && next build", "start": "next start -p 3002", "lint": "next lint", + "test": "bun test", "format": "prettier --write \"**/*.{ts,js,json,md}\"", "format:check": "prettier --check \"**/*.{ts,js,json,md}\"" }, @@ -34,6 +37,7 @@ "@clerk/themes": "^2.4.19", "@mcp-ui/server": "^5.10.0", "@modelcontextprotocol/sdk": "1.26.0", + "@onkernel/managed-auth-react": "0.4.1", "@onkernel/sdk": "^0.78.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -55,6 +59,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.3.14", "@types/jszip": "^3.4.1", "@types/node": "^20", "@types/react": "^19", diff --git a/scripts/build-managed-auth-app.mjs b/scripts/build-managed-auth-app.mjs new file mode 100644 index 0000000..e1c91b7 --- /dev/null +++ b/scripts/build-managed-auth-app.mjs @@ -0,0 +1,89 @@ +// Builds the managed-auth MCP App into a single self-contained HTML bundle. +// The --check mode is byte-exact, and Bun's minifier output can change between +// releases, so the bundle is only reproducible with the Bun version pinned in +// .github/workflows/ci.yml (currently 1.3.3). Regenerate the bundle with that +// exact version: bun run build:managed-auth-app +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const entrypoint = join(root, "src/lib/mcp/apps/managed-auth-entry.tsx"); +const generatedPath = join( + root, + "src/lib/mcp/apps/generated/managed-auth-app.ts", +); +const check = process.argv.includes("--check"); +const temp = await mkdtemp(join(tmpdir(), "kernel-managed-auth-app-")); + +try { + const build = await Bun.build({ + entrypoints: [entrypoint], + outdir: temp, + target: "browser", + format: "esm", + minify: true, + splitting: false, + sourcemap: "none", + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + }); + + if (!build.success) { + for (const log of build.logs) console.error(log); + process.exitCode = 1; + } else { + let javascript = ""; + let css = ""; + for (const output of build.outputs) { + if (output.path.endsWith(".js")) javascript += await output.text(); + if (output.path.endsWith(".css")) css += await output.text(); + } + if (!javascript) + throw new Error("Bun did not emit managed-auth JavaScript"); + + const escapeScript = (value) => value.replaceAll(" value.replaceAll(" + + + + +Kernel Managed Authentication + + +
Preparing secure login…
+ + +`; + const generated = `// Generated by scripts/build-managed-auth-app.mjs. Do not edit.\nexport const MANAGED_AUTH_APP_HTML = ${JSON.stringify(html)};\n`; + + if (check) { + let current = ""; + try { + current = await readFile(generatedPath, "utf8"); + } catch { + // Report the same actionable stale-bundle error below. + } + if (current !== generated) { + console.error( + "Managed-auth App bundle is stale. Run: bun run build:managed-auth-app", + ); + process.exitCode = 1; + } + } else { + await mkdir(resolve(generatedPath, ".."), { recursive: true }); + await writeFile(generatedPath, generated); + console.log(`Generated ${generatedPath}`); + } + } +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/src/app/[transport]/route.ts b/src/app/[transport]/route.ts index 77648bb..a8a837f 100644 --- a/src/app/[transport]/route.ts +++ b/src/app/[transport]/route.ts @@ -6,6 +6,30 @@ import { verifyToken } from "@clerk/nextjs/server"; import { NextRequest } from "next/server"; import { isValidJwtFormat } from "@/lib/auth-utils"; import { registerMcpCapabilities } from "@/lib/mcp/register"; +import { initializeDeclaresMcpApps } from "@/lib/mcp/tools/auth-login-app"; +import { markMcpAppsClient } from "@/lib/redis"; + +// The streamable-HTTP transport is stateless (one McpServer per request), so +// the client's declared MCP Apps capability is invisible to later tool calls. +// Observe initialize here — where the bearer token is available — and record +// the capability per token so app-only tools can fail closed on hosts that +// never declared MCP Apps support. +async function recordMcpAppsCapability( + req: NextRequest, + token: string, + ttlSeconds: number, +): Promise { + if (req.method !== "POST") return; + try { + const body = await req.clone().json(); + if (!initializeDeclaresMcpApps(body)) return; + await markMcpAppsClient({ token, ttlSeconds }); + } catch (error) { + // Never block the initialize handshake on the marker; gated tools fail + // closed if the record is missing. + console.error("Failed to record MCP Apps capability:", error); + } +} export async function OPTIONS(_req: NextRequest): Promise { return new Response(null, { @@ -59,6 +83,9 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { } if (!isValidJwtFormat(token)) { + // Static API keys have no expiry; use a long sliding TTL (refreshed on + // every gated app-only call and on re-initialize). + await recordMcpAppsCapability(req, token, 24 * 60 * 60); const authHandler = withMcpAuth( handler, async () => ({ @@ -87,6 +114,11 @@ async function handleAuthenticatedRequest(req: NextRequest): Promise { ); } + // The marker is keyed by the session (sid), which survives access-token + // refresh, so bind it to the long sliding TTL (refreshed on every gated + // app-only call) rather than this one token's lifetime. + await recordMcpAppsCapability(req, token, 24 * 60 * 60); + // Create authenticated handler with auth info const authHandler = withMcpAuth( handler, diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts new file mode 100644 index 0000000..06ebe97 --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test"; +import { proxyManagedAuthRequest } from "./route"; + +function jwt(claims: Record) { + const encode = (value: unknown) => + btoa(JSON.stringify(value)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return `${encode({ alg: "none" })}.${encode(claims)}.signature`; +} + +const scopedToken = jwt({ + iss: "kernel-api", + managed_auth_session_id: "session_1", + exp: 4102444800, +}); + +function request( + path: string, + options: { + method?: string; + headers?: Record; + body?: string; + } = {}, +) { + return new Request(`http://localhost:3002${path}`, { + method: options.method ?? "GET", + headers: options.headers, + body: options.body, + }); +} + +function expectCors(response: Response) { + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toBe( + "GET, POST, OPTIONS", + ); +} + +describe("managed-auth relay", () => { + test("rejects invalid paths, methods, query parameters, and API keys", async () => { + const invalidPath = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/arbitrary"), + ["c_1", "arbitrary"], + ); + expect(invalidPath.status).toBe(404); + expectCors(invalidPath); + + const invalidMethod = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + method: "POST", + }), + ["c_1", "events"], + ); + expect(invalidMethod.status).toBe(405); + expectCors(invalidMethod); + + const query = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1?upstream=evil"), + ["c_1"], + ); + expect(query.status).toBe(400); + + const apiKey = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1", { + headers: { authorization: "Bearer sk_not_a_managed_auth_jwt" }, + }), + ["c_1"], + ); + expect(apiKey.status).toBe(401); + expectCors(apiKey); + }); + + test("rejects expired scoped JWTs at the relay boundary", async () => { + const expired = jwt({ + iss: "kernel-api", + managed_auth_session_id: "session_1", + exp: 1700000000, // 2023-11-14, in the past + }); + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1", { + headers: { authorization: `Bearer ${expired}` }, + }), + ["c_1"], + ); + expect(response.status).toBe(401); + expectCors(response); + }); + + test("allows unauthenticated exchange and strips cookies and arbitrary headers", async () => { + let forwarded: RequestInit | undefined; + const upstream = async (_url: URL | RequestInfo, init?: RequestInit) => { + forwarded = init; + return new Response('{"jwt":"scoped-secret"}', { + headers: { "content-type": "application/json", "set-cookie": "bad=1" }, + }); + }; + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + headers: { + cookie: "mcp=secret", + "x-forwarded-for": "127.0.0.1", + "x-arbitrary": "do-not-forward", + "content-type": "application/json", + accept: "application/json", + }, + body: '{"code":"handoff-secret"}', + }), + ["c_1", "exchange"], + upstream as typeof fetch, + ); + expect(response.status).toBe(200); + expectCors(response); + const headers = new Headers(forwarded?.headers); + expect(headers.get("cookie")).toBeNull(); + expect(headers.get("x-forwarded-for")).toBeNull(); + expect(headers.get("x-arbitrary")).toBeNull(); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("content-type")).toBe("application/json"); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + test("passes scoped JWT only to fixed authenticated endpoints", async () => { + let forwardedAuthorization = ""; + const upstream = async (_url: URL | RequestInfo, init?: RequestInit) => { + forwardedAuthorization = + new Headers(init?.headers).get("authorization") ?? ""; + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }; + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/submit", { + method: "POST", + headers: { + authorization: `Bearer ${scopedToken}`, + "content-type": "application/json", + }, + body: "{}", + }), + ["c_1", "submit"], + upstream as typeof fetch, + ); + expect(response.status).toBe(200); + expect(forwardedAuthorization).toBe(`Bearer ${scopedToken}`); + }); + + test("preserves unbuffered SSE and CORS preflight", async () => { + const upstream = async () => + new Response("event: status\ndata: {}\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + const response = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + headers: { authorization: `Bearer ${scopedToken}` }, + }), + ["c_1", "events"], + upstream as unknown as typeof fetch, + ); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(response.headers.get("cache-control")).toBe( + "no-cache, no-transform", + ); + expect(response.headers.get("x-accel-buffering")).toBe("no"); + expect(response.headers.get("content-encoding")).toBe("identity"); + expectCors(response); + + const preflight = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/events", { + method: "OPTIONS", + }), + ["c_1", "events"], + ); + expect(preflight.status).toBe(204); + expectCors(preflight); + }); + + test("enforces the request body limit and does not log secrets", async () => { + const tooLarge = await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + headers: { "content-length": String(65 * 1024) }, + body: "x", + }), + ["c_1", "exchange"], + ); + expect(tooLarge.status).toBe(413); + expectCors(tooLarge); + + let canceled = false; + const chunkedBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array(32 * 1024)); + controller.enqueue(new Uint8Array([1])); + }, + cancel() { + canceled = true; + }, + }); + const chunked = await proxyManagedAuthRequest( + new Request( + "http://localhost:3002/managed-auth-proxy/auth/connections/c_1/exchange", + { method: "POST", body: chunkedBody }, + ), + ["c_1", "exchange"], + ); + expect(chunked.status).toBe(413); + expect(canceled).toBe(true); + + const calls: unknown[][] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args) => calls.push(args); + console.error = (...args) => calls.push(args); + try { + await proxyManagedAuthRequest( + request("/managed-auth-proxy/auth/connections/c_1/exchange", { + method: "POST", + body: '{"code":"never-log-this"}', + }), + ["c_1", "exchange"], + (async () => + new Response( + '{"jwt":"never-log-this-either"}', + )) as unknown as typeof fetch, + ); + } finally { + console.log = originalLog; + console.error = originalError; + } + expect(calls).toHaveLength(0); + }); +}); diff --git a/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts new file mode 100644 index 0000000..7324d5e --- /dev/null +++ b/src/app/managed-auth-proxy/auth/connections/[...path]/route.ts @@ -0,0 +1,203 @@ +const MAX_BODY_BYTES = 64 * 1024; +const ALLOWED_REQUEST_HEADERS = ["authorization", "content-type", "accept"]; +const ALLOWED_METHODS = "GET, POST, OPTIONS"; + +type RouteContext = { params: Promise<{ path: string[] }> }; +type FetchLike = typeof fetch; + +function corsHeaders(cacheControl = "private, no-store") { + return new Headers({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": ALLOWED_METHODS, + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept", + "Cache-Control": cacheControl, + }); +} + +function responseError(status: number, message: string) { + return new Response(message, { status, headers: corsHeaders() }); +} + +function validOperation(path: string[], method: string): boolean { + if (path.length === 1 && method === "GET") return path[0].length > 0; + if (path.length !== 2 || !path[0] || !path[1]) return false; + if (method === "POST") { + return path[1] === "exchange" || path[1] === "submit"; + } + return method === "GET" && path[1] === "events"; +} + +function pathExists(path: string[]): boolean { + return ( + (path.length === 1 && !!path[0]) || + (path.length === 2 && + !!path[0] && + ["exchange", "submit", "events"].includes(path[1])) + ); +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (normalized.length % 4)) % 4); + return JSON.parse(atob(normalized + padding)) as Record; + } catch { + return null; + } +} + +function managedAuthAuthorization(request: Request): string | null { + const authorization = request.headers.get("authorization"); + const match = authorization?.match(/^Bearer\s+([^\s]+)$/i); + if (!match) return null; + const claims = decodeJwtPayload(match[1]); + if ( + claims?.iss !== "kernel-api" || + typeof claims.managed_auth_session_id !== "string" || + !claims.managed_auth_session_id || + typeof claims.exp !== "number" || + // Reject expired session JWTs at the relay boundary instead of + // forwarding them upstream. + claims.exp * 1000 <= Date.now() + ) { + return null; + } + return authorization; +} + +async function readSmallBody(request: Request): Promise { + const length = request.headers.get("content-length"); + if (length) { + const parsedLength = Number(length); + if ( + !Number.isSafeInteger(parsedLength) || + parsedLength < 0 || + parsedLength > MAX_BODY_BYTES + ) { + await request.body?.cancel(); + return null; + } + } + + if (!request.body) return new ArrayBuffer(0); + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body.buffer; +} + +export async function proxyManagedAuthRequest( + request: Request, + path: string[], + fetchUpstream: FetchLike = fetch, +): Promise { + if (new URL(request.url).search) { + return responseError(400, "Query parameters are not allowed"); + } + + if (request.method === "OPTIONS") { + return pathExists(path) + ? new Response(null, { status: 204, headers: corsHeaders() }) + : responseError(404, "Not found"); + } + + if (!pathExists(path)) return responseError(404, "Not found"); + if (!validOperation(path, request.method)) { + return responseError(405, "Method not allowed"); + } + + const isExchange = path.length === 2 && path[1] === "exchange"; + const authorization = isExchange ? null : managedAuthAuthorization(request); + if (!isExchange && !authorization) { + return responseError(401, "Invalid managed-auth authorization"); + } + + let body: ArrayBuffer | undefined; + if (request.method === "POST") { + const requestBody = await readSmallBody(request); + if (requestBody === null) { + return responseError(413, "Request body too large"); + } + body = requestBody; + } + + const upstreamHeaders = new Headers(); + for (const name of ALLOWED_REQUEST_HEADERS) { + if (name === "authorization") continue; + const value = request.headers.get(name); + if (value) upstreamHeaders.set(name, value); + } + if (authorization) upstreamHeaders.set("authorization", authorization); + + const baseUrl = process.env.API_BASE_URL ?? "https://api.onkernel.com"; + const upstreamUrl = new URL( + `/auth/connections/${path.map(encodeURIComponent).join("/")}`, + baseUrl, + ); + const upstream = await fetchUpstream(upstreamUrl, { + method: request.method, + headers: upstreamHeaders, + ...(body && { body }), + redirect: "manual", + }); + + if (upstream.status >= 300 && upstream.status < 400) { + return responseError(502, "Upstream redirect rejected"); + } + + const isEvents = path.length === 2 && path[1] === "events"; + const headers = corsHeaders( + isEvents ? "no-cache, no-transform" : "private, no-store", + ); + const contentType = upstream.headers.get("content-type"); + if (isEvents) { + headers.set("Content-Type", "text/event-stream"); + headers.set("X-Accel-Buffering", "no"); + headers.set("Content-Encoding", "identity"); + } else if (contentType) { + headers.set("Content-Type", contentType); + } + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers, + }); +} + +async function handle(request: Request, context: RouteContext) { + const { path } = await context.params; + try { + return await proxyManagedAuthRequest(request, path); + } catch { + return responseError(502, "Managed-auth relay unavailable"); + } +} + +export const GET = handle; +export const HEAD = handle; +export const POST = handle; +export const OPTIONS = handle; +export const DELETE = handle; +export const PATCH = handle; +export const PUT = handle; diff --git a/src/lib/mcp-apps-marker.test.ts b/src/lib/mcp-apps-marker.test.ts new file mode 100644 index 0000000..0e3782b --- /dev/null +++ b/src/lib/mcp-apps-marker.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { mcpAppsMarkerSubject } from "@/lib/mcp-apps-marker"; + +process.env.CLERK_SECRET_KEY ??= "test-clerk-secret"; + +function jwt(claims: Record) { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(claims)}.signature`; +} + +describe("MCP Apps marker subject", () => { + test("survives OAuth access-token refresh via the session id", () => { + const first = jwt({ sub: "user_1", sid: "sess_1", exp: 100 }); + const refreshed = jwt({ + sub: "user_1", + sid: "sess_1", + exp: 200, + iat: 150, + }); + expect(mcpAppsMarkerSubject(first)).toBe("sid:sess_1"); + expect(mcpAppsMarkerSubject(refreshed)).toBe("sid:sess_1"); + }); + + test("keys distinct sessions apart", () => { + expect(mcpAppsMarkerSubject(jwt({ sid: "sess_2" }))).toBe("sid:sess_2"); + expect(mcpAppsMarkerSubject(jwt({ sid: "sess_2" }))).not.toBe( + mcpAppsMarkerSubject(jwt({ sid: "sess_3" })), + ); + }); + + test("falls back to the token hash for API keys and sid-less JWTs", () => { + const apiKey = mcpAppsMarkerSubject("sk_test_key"); + expect(apiKey.startsWith("token:")).toBe(true); + expect(mcpAppsMarkerSubject("sk_test_key")).toBe(apiKey); + expect(mcpAppsMarkerSubject("sk_other_key")).not.toBe(apiKey); + + const sidlessA = jwt({ sub: "user_1", exp: 100 }); + const sidlessB = jwt({ sub: "user_1", exp: 200 }); + expect(mcpAppsMarkerSubject(sidlessA).startsWith("token:")).toBe(true); + expect(mcpAppsMarkerSubject(sidlessA)).not.toBe( + mcpAppsMarkerSubject(sidlessB), + ); + }); +}); diff --git a/src/lib/mcp-apps-marker.ts b/src/lib/mcp-apps-marker.ts new file mode 100644 index 0000000..6882361 --- /dev/null +++ b/src/lib/mcp-apps-marker.ts @@ -0,0 +1,39 @@ +import { createHmac } from "crypto"; + +function hashBearerToken(token: string): string { + const secretKey = process.env.CLERK_SECRET_KEY; + if (!secretKey) { + throw new Error("CLERK_SECRET_KEY environment variable must be set"); + } + return createHmac("sha256", secretKey).update(token).digest("hex"); +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = "=".repeat((4 - (normalized.length % 4)) % 4); + return JSON.parse( + Buffer.from(normalized + padding, "base64").toString("utf8"), + ) as Record; + } catch { + return null; + } +} + +/** + * Stable identity for a client's MCP Apps capability marker. OAuth access + * tokens rotate on refresh while the session (sid) survives, so key JWT + * sessions by sid; anything else (static API keys, sid-less JWTs) falls back + * to the token hash. The payload is only decoded, not verified: markers are + * recorded solely after the route layer verified the token, and gated calls + * only ever see verified tokens, so a sid here always belongs to the caller. + */ +export function mcpAppsMarkerSubject(token: string): string { + const claims = decodeJwtPayload(token); + if (claims && typeof claims.sid === "string" && claims.sid) { + return `sid:${claims.sid}`; + } + return `token:${hashBearerToken(token)}`; +} diff --git a/src/lib/mcp/apps/generated/managed-auth-app.ts b/src/lib/mcp/apps/generated/managed-auth-app.ts new file mode 100644 index 0000000..a23a3da --- /dev/null +++ b/src/lib/mcp/apps/generated/managed-auth-app.ts @@ -0,0 +1,2 @@ +// Generated by scripts/build-managed-auth-app.mjs. Do not edit. +export const MANAGED_AUTH_APP_HTML = "\n\n\n\n\nKernel Managed Authentication\n\n\n
Preparing secure login…
\n\n\n"; diff --git a/src/lib/mcp/apps/managed-auth-entry.tsx b/src/lib/mcp/apps/managed-auth-entry.tsx new file mode 100644 index 0000000..f15d7c6 --- /dev/null +++ b/src/lib/mcp/apps/managed-auth-entry.tsx @@ -0,0 +1,782 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + AppearanceProvider, + KernelManagedAuth, + LocalizationProvider, + Shell, + StepError, + StepPrime, + StepSuccess, +} from "@onkernel/managed-auth-react"; +import "@onkernel/managed-auth-react/styles.css"; + +const FAILURE_CONTEXT = + "Managed authentication stopped. Verify its terminal state and report the recovery option; do not continue the protected action."; + +type JsonObject = Record; +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; +}; +type SafeConnection = { + id: string; + domain: string; + profile_name: string; + status: "AUTHENTICATED" | "NEEDS_AUTH"; + flow_status: + | "IN_PROGRESS" + | "SUCCESS" + | "FAILED" + | "EXPIRED" + | "CANCELED" + | null; + flow_type: "LOGIN" | "REAUTH" | null; + flow_expires_at: string | null; +}; +type BeginResult = { + structuredContent?: { + state?: string; + connection?: SafeConnection; + resume_id?: string; + app_private?: { + handoff_code?: string; + hosted_url?: string; + relay_base_url?: string; + }; + }; + _meta?: { + auth_login?: { + handoff_code?: string; + hosted_url?: string; + relay_base_url?: string; + }; + }; + isError?: boolean; +}; +type WaitToolResult = { + structuredContent?: { + state?: "authenticated" | "failed" | "pending"; + }; +}; + +let nextRequestId = 1; +const pendingRequests = new Map(); +// Reply target origin learned from the first validated host message. Outbound +// messages can carry capability-bearing URLs (the hosted fallback via +// ui/open-link), so once the host's origin is known we stop broadcasting to +// "*". The "*" fallback only exists because opaque-origin sandbox iframes +// cannot know the host origin before the first inbound message. +let hostOrigin: string | null = null; +let destroyed = false; +let collapsed = false; +let reactRoot: Root | null = null; +let completeToolInput: JsonObject | null = null; +let launcherToolResult: JsonObject | null = null; +let hostTheme: "light" | "dark" | "auto" = "auto"; +const listeners = new Set<() => void>(); +let stateVersion = 0; +const oneShotKeys = new Set(); + +function claimOneShot(key: string): boolean { + if (oneShotKeys.has(key)) return false; + try { + if (sessionStorage.getItem(key)) return false; + sessionStorage.setItem(key, "1"); + } catch { + // Opaque sandbox origins may deny storage; the in-memory guard still works. + } + oneShotKeys.add(key); + return true; +} + +function notifyStateChanged() { + stateVersion += 1; + for (const listener of listeners) listener(); +} + +function postToHost(message: JsonObject) { + // The "*" fallback is only reachable before the host's first inbound + // message (opaque-origin sandboxes cannot know the host origin earlier); + // those early messages are the ui/initialize handshake and carry no + // capability-bearing data. Everything after is pinned to hostOrigin. + // nosemgrep + window.parent.postMessage(message, hostOrigin ?? "*"); +} + +function sendRequest(method: string, params: JsonObject): Promise { + const id = nextRequestId++; + return new Promise((resolve, reject) => { + pendingRequests.set(id, { resolve, reject }); + postToHost({ jsonrpc: "2.0", id, method, params }); + }); +} + +function sendNotification(method: string, params: JsonObject) { + postToHost({ jsonrpc: "2.0", method, params }); +} + +function callTool(name: string, args: JsonObject): Promise { + return sendRequest("tools/call", { + name, + arguments: args, + }) as Promise; +} + +function applyHostContext(context: JsonObject | undefined) { + const theme = context?.theme; + if (theme === "light" || theme === "dark") hostTheme = theme; + const styles = context?.styles as + | { variables?: Record } + | undefined; + if (styles?.variables) { + for (const [key, value] of Object.entries(styles.variables)) { + document.documentElement.style.setProperty(key, value); + } + } + notifyStateChanged(); +} + +function reportSize() { + sendNotification("ui/notifications/size-changed", { + height: collapsed + ? 1 + : Math.max(document.documentElement.scrollHeight, 360), + }); +} + +window.addEventListener("message", (event: MessageEvent) => { + if (event.source !== window.parent) return; + // Pin replies to the host's origin once known. Ignore "null" (opaque + // origins): they cannot be targeted, so the "*" fallback stays in effect. + if (!hostOrigin && event.origin && event.origin !== "null") { + hostOrigin = event.origin; + } + const message = event.data as + | { + jsonrpc?: string; + id?: number; + method?: string; + params?: JsonObject; + result?: unknown; + error?: { message?: string }; + } + | undefined; + if (!message || message.jsonrpc !== "2.0") return; + + if (message.id !== undefined && !message.method) { + const pending = pendingRequests.get(message.id); + if (!pending) return; + pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message ?? "Host request failed")); + } else { + pending.resolve(message.result); + } + return; + } + + if (message.method === "ui/resource-teardown" && message.id !== undefined) { + destroyed = true; + for (const pending of pendingRequests.values()) { + pending.reject(new Error("Managed-auth App was closed")); + } + pendingRequests.clear(); + listeners.clear(); + reactRoot?.unmount(); + reactRoot = null; + postToHost({ jsonrpc: "2.0", id: message.id, result: {} }); + return; + } + + if (message.id !== undefined && message.method) { + postToHost({ jsonrpc: "2.0", id: message.id, result: {} }); + return; + } + + switch (message.method) { + case "ui/notifications/tool-input": + completeToolInput = message.params?.arguments as JsonObject; + notifyStateChanged(); + break; + case "ui/notifications/tool-result": + launcherToolResult = message.params ?? null; + notifyStateChanged(); + break; + case "ui/notifications/host-context-changed": + applyHostContext(message.params); + break; + } +}); + +function useLauncherData() { + useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => stateVersion, + ); + return { + input: completeToolInput, + result: launcherToolResult, + theme: hostTheme, + }; +} + +function isRetryableHttpStatus(status: number): boolean { + return status === 408 || status === 425 || status === 429 || status >= 500; +} + +function sanitizeBeginArguments(input: JsonObject): JsonObject { + const allowed = [ + "mode", + "connection_id", + "domain", + "profile_name", + "save_credentials", + "proxy_id", + "proxy_name", + ]; + return Object.fromEntries( + allowed + .filter((key) => input[key] !== undefined) + .map((key) => [key, input[key]]), + ); +} + +function waitArgumentsFromLauncher( + content: JsonObject | undefined, +): JsonObject | null { + const nextAction = content?.next_action as + | { tool?: unknown; arguments?: JsonObject } + | undefined; + if ( + nextAction?.tool !== "manage_auth_connections" || + nextAction.arguments?.action !== "wait" + ) { + return null; + } + const allowed = [ + "action", + "id", + "domain_filter", + "profile_name", + "required_flow_type", + "previous_flow_expires_at", + "previous_flow_event_id", + "flow_wait_started_at", + ]; + return { + ...Object.fromEntries( + allowed + .filter((key) => nextAction.arguments?.[key] !== undefined) + .map((key) => [key, nextAction.arguments?.[key]]), + ), + wait_seconds: 1, + }; +} + +function ManagedAuthApp() { + const launcher = useLauncherData(); + const [beginResult, setBeginResult] = useState(null); + const [starting, setStarting] = useState(false); + const [terminal, setTerminal] = useState<"success" | "failure" | null>(null); + const [dismissed, setDismissed] = useState(false); + const [statusText, setStatusText] = useState(""); + const [embeddedInitFailed, setEmbeddedInitFailed] = useState(false); + const [embeddedRetryRequired, setEmbeddedRetryRequired] = useState(false); + const pollTimer = useRef(null); + const pollingStarted = useRef(false); + const mountedFlow = useRef(false); + const hostedFallbackAvailable = useRef(true); + const exchangedJwt = useRef(null); + const retrieveInitializationFailed = useRef(false); + + const launcherContent = launcher.result?.structuredContent as + | { + connection?: { domain?: string; profile_name?: string }; + next_action?: { tool?: string; arguments?: JsonObject }; + } + | undefined; + const targetDomain = + (launcherContent?.connection?.domain as string | undefined) ?? + (launcher.input?.domain as string | undefined) ?? + "this site"; + const connection = beginResult?.structuredContent?.connection; + const resumeId = beginResult?.structuredContent?.resume_id; + const privateAuth = + beginResult?._meta?.auth_login ?? + beginResult?.structuredContent?.app_private; + const waitArguments = waitArgumentsFromLauncher( + launcher.result?.structuredContent as JsonObject | undefined, + ); + + const appearance = useMemo( + () => ({ theme: launcher.theme, layout: { skipPrimeStep: true } }), + [launcher.theme], + ); + + const managedAuthFetch = useCallback( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const pathname = new URL(url, window.location.href).pathname; + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + const isExchange = + method === "POST" && + /\/auth\/connections\/[^/]+\/exchange$/.test(pathname); + const isRetrieve = + method === "GET" && /\/auth\/connections\/[^/]+$/.test(pathname); + + if (isExchange && exchangedJwt.current) { + return new Response(JSON.stringify({ jwt: exchangedJwt.current }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const attempts = isRetrieve ? 3 : 1; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(input, init); + if (isExchange && !isRetryableHttpStatus(response.status)) { + // A definitive exchange response means the hosted URL is no longer + // a safe fallback. Retryable failures leave it for the hosted UI. + hostedFallbackAvailable.current = false; + if (response.ok) { + const data = (await response.clone().json()) as { jwt?: unknown }; + if (typeof data.jwt === "string") exchangedJwt.current = data.jwt; + } + } + if (isRetrieve) { + if (response.ok) retrieveInitializationFailed.current = false; + else if ( + isRetryableHttpStatus(response.status) && + attempt + 1 < attempts + ) { + await new Promise((resolve) => + window.setTimeout(resolve, 250 * (attempt + 1)), + ); + continue; + } else { + retrieveInitializationFailed.current = isRetryableHttpStatus( + response.status, + ); + } + } + return response; + } catch (error) { + if (isRetrieve && attempt + 1 < attempts) { + await new Promise((resolve) => + window.setTimeout(resolve, 250 * (attempt + 1)), + ); + continue; + } + if (isRetrieve) retrieveInitializationFailed.current = true; + throw error; + } + } + throw new Error("Managed-auth request retry exhausted"); + }, + [], + ); + + useEffect(() => { + reportSize(); + }); + + useEffect(() => { + return () => { + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + }; + }, []); + + function terminalContext(outcome: "success" | "failure") { + if (outcome === "failure") { + return { + text: FAILURE_CONTEXT, + structuredContent: { + kind: "kernel.managed_auth.terminal", + version: 1, + outcome, + }, + }; + } + + const domain = connection?.domain ?? targetDomain; + const profileName = connection?.profile_name; + const safeTarget = { + domain, + ...(profileName && { profile_name: profileName }), + }; + return { + text: `Kernel managed authentication reported completion for ${JSON.stringify(safeTarget)}. Verify the connection through manage_auth_connections before continuing the pending task.`, + structuredContent: { + kind: "kernel.managed_auth.terminal", + version: 1, + outcome, + ...safeTarget, + }, + }; + } + + async function publishTerminal(outcome: "success" | "failure") { + if (!resumeId || destroyed) return; + const contextKey = `kernel-managed-auth-context:${resumeId}:${outcome}`; + if (!claimOneShot(contextKey)) return; + const context = terminalContext(outcome); + try { + await sendRequest("ui/update-model-context", { + content: [{ type: "text", text: context.text }], + structuredContent: context.structuredContent, + }); + } catch { + // The verified terminal state remains visible in the App. + } + } + + function finish(outcome: "success" | "failure") { + if (terminal || destroyed) return; + if (pollTimer.current !== null) window.clearTimeout(pollTimer.current); + setTerminal(outcome); + setStatusText(""); + void publishTerminal(outcome); + } + + async function checkStatus() { + if (!connection?.id || destroyed || terminal) return; + const knownExpiry = connection.flow_expires_at + ? Date.parse(connection.flow_expires_at) + : Number.NaN; + try { + const result = await callTool("get_auth_login_status", { + connection_id: connection.id, + }); + const current = result.structuredContent?.connection; + if (!current) throw new Error("No connection status returned"); + const expiresAt = current.flow_expires_at + ? Date.parse(current.flow_expires_at) + : Number.NaN; + const statusClaimsFailure = + current.flow_status === "FAILED" || + current.flow_status === "EXPIRED" || + current.flow_status === "CANCELED" || + (current.flow_status !== "SUCCESS" && + Number.isFinite(expiresAt) && + expiresAt <= Date.now()); + const statusClaimsSuccess = + current.flow_status === "SUCCESS" && current.status === "AUTHENTICATED"; + if ((statusClaimsFailure || statusClaimsSuccess) && waitArguments) { + const wait = await callTool( + "manage_auth_connections", + waitArguments, + ); + const waitState = wait.structuredContent?.state; + if (waitState === "authenticated") { + finish("success"); + return; + } + if (waitState === "failed") { + finish("failure"); + return; + } + setStatusText("Secure login is still in progress…"); + pollTimer.current = window.setTimeout(checkStatus, 2000); + return; + } + if (statusClaimsFailure) { + finish("failure"); + return; + } + if (statusClaimsSuccess) { + finish("success"); + return; + } + setStatusText("Secure login is still in progress…"); + pollTimer.current = window.setTimeout(checkStatus, 2000); + } catch { + if (Number.isFinite(knownExpiry) && knownExpiry <= Date.now()) { + finish("failure"); + return; + } + setStatusText("Checking secure login status…"); + pollTimer.current = window.setTimeout(checkStatus, 3000); + } + } + + async function begin() { + if (!launcher.input || starting) { + setStatusText("Secure login input is unavailable. Close and retry."); + return; + } + setStarting(true); + setStatusText("Preparing secure login…"); + try { + const result = await callTool("begin_auth_login", { + ...sanitizeBeginArguments(launcher.input), + }); + if (result.isError || !result.structuredContent?.connection) { + throw new Error("Secure login could not be prepared"); + } + setBeginResult(result); + setStatusText(""); + } catch { + setStatusText( + "Secure login could not start. Close this panel and retry.", + ); + } finally { + setStarting(false); + } + } + + useEffect(() => { + if (!beginResult || !connection || terminal || pollingStarted.current) { + return; + } + if (beginResult.structuredContent?.state === "already_authenticated") { + finish("success"); + return; + } + if ( + beginResult.structuredContent?.state === "observing" || + !privateAuth?.handoff_code || + embeddedInitFailed + ) { + pollingStarted.current = true; + pollTimer.current = window.setTimeout(checkStatus, 500); + } + }); + + function closePanel() { + collapsed = true; + setDismissed(true); + window.setTimeout(reportSize, 0); + } + + if (dismissed) { + return