From 4d893c16ba4b475d84c1ab49ee70454e1e9e844d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 30 May 2026 17:15:34 -0400 Subject: [PATCH 1/2] feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…` query string) with empty sessionStorage made every `/api/*` request 401 — the browser had no way to recover the backend's auth token. Embed the token into `index.html` on every page load so the browser no longer depends on the query string surviving navigation: - New shared helper `clients/web/server/inject-auth-token.ts` embeds `` (escaped against `` injection; no-op when auth is dangerously omitted). - Dev: the Vite plugin injects via `transformIndexHtml`. - Prod: the Hono server injects on the `/` route. - `App.tsx` `getAuthToken()` now reads the injected global first, then the query string, then sessionStorage (both fallbacks preserved). - Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL` (`core/mcp/remote/constants.ts`). Tests: helper unit coverage + an integration test exercising the real prod server's `/` → `/api/*` flow (injected token authenticates; missing token 401s). AGENTS.md documents the token-recovery order. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 11 ++ clients/web/server/inject-auth-token.ts | 51 +++++++++ clients/web/server/server.ts | 7 +- clients/web/server/vite-hono-plugin.ts | 17 +++ clients/web/src/App.tsx | 30 +++-- .../server/inject-auth-token.test.ts | 67 ++++++++++++ .../server/server-token-injection.test.ts | 103 ++++++++++++++++++ core/mcp/remote/constants.ts | 11 ++ 8 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 clients/web/server/inject-auth-token.ts create mode 100644 clients/web/src/test/integration/server/inject-auth-token.test.ts create mode 100644 clients/web/src/test/integration/server/server-token-injection.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1c05b22d3..ac743b3e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ inspector/ │ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher), │ │ │ # web-server-config.ts (env parsing + initial-config payload + banner), │ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server), +│ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions) │ │ └── static/ # sandbox_proxy.html (served by sandbox-controller for MCP Apps tab) │ ├── cli/ # CLI client @@ -66,6 +67,16 @@ inspector/ * The InspectorClient from v1.5/main will be merged into v2/main, and wired up to the new web Inspector. The TUI and CLI will follow. Eventually when everything works on v2/main we will replace main with v2/main, eliminating the legacy implementations. +## Web backend auth token + +The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: Bearer `. The browser recovers that token from three sources, in priority order (see `App.tsx` `getAuthToken()`): + +1. `window.__INSPECTOR_API_TOKEN__` — injected into `index.html` on every page load by the backend (the dev Vite plugin via `transformIndexHtml`, the prod Hono server on the `/` route), both routed through `clients/web/server/inject-auth-token.ts`. This is what makes a bare-URL reload, a bookmark, or a cleared `sessionStorage` keep working. +2. `?MCP_INSPECTOR_API_TOKEN=…` query string — the URL the launcher banner prints; kept as a fallback for pasted full URLs. +3. `sessionStorage` — backstop for navigations that land without either of the above. + +Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`. + ## Maintenance Rules ### Keep documentation files up to date diff --git a/clients/web/server/inject-auth-token.ts b/clients/web/server/inject-auth-token.ts new file mode 100644 index 000000000..c066c0454 --- /dev/null +++ b/clients/web/server/inject-auth-token.ts @@ -0,0 +1,51 @@ +/** + * Embed the remote API auth token into the served `index.html` so the browser + * doesn't depend on the `?MCP_INSPECTOR_API_TOKEN=…` query string surviving + * navigation, bookmarks, or a hand-typed reload at the bare URL. The dev Vite + * plugin (`vite-hono-plugin.ts`) and the prod Hono server (`server.ts`) both + * funnel through this single helper so the injected shape stays identical + * across both backends. `App.tsx`'s `getAuthToken()` reads the embedded global + * ahead of the URL / sessionStorage fallbacks. + */ + +import { INSPECTOR_API_TOKEN_GLOBAL } from "../../../core/mcp/remote/constants.ts"; + +/** + * Serialize the token as a JS string literal safe to drop inside an inline + * `` would otherwise close + * the tag early. The token is normally a hex string, but it can be a + * user-supplied value (`MCP_INSPECTOR_API_TOKEN` env / `--auth-token`), so we + * don't assume it's benign. + */ +function serializeTokenForScript(token: string): string { + return JSON.stringify(token).replace(/window.__INSPECTOR_API_TOKEN__ = "…"` + * tag injected. The script is placed just before `` when present, else + * just before ``, else prepended — in every case it runs before the app + * bundle (which lives further down the document) so the global is set by the + * time `getAuthToken()` reads it. + * + * An empty `token` (auth disabled via `DANGEROUSLY_OMIT_AUTH`) is a no-op: the + * page is returned untouched and no global is defined, matching the banner's + * "no token in the URL" behavior. + */ +export function injectAuthToken(html: string, token: string): string { + if (!token) return html; + const script = ``; + const headClose = html.indexOf(""); + if (headClose !== -1) { + return html.slice(0, headClose) + script + html.slice(headClose); + } + const bodyClose = html.indexOf(""); + if (bodyClose !== -1) { + return html.slice(0, bodyClose) + script + html.slice(bodyClose); + } + return script + html; +} diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index b0b0e5d5d..0f47a59fb 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -13,6 +13,7 @@ import { serveStatic } from "@hono/node-server/serve-static"; import { Hono } from "hono"; import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts"; import { createSandboxController } from "./sandbox-controller.js"; +import { injectAuthToken } from "./inject-auth-token.js"; import type { WebServerConfig } from "./web-server-config.js"; import { webServerConfigToInitialPayload, @@ -64,7 +65,11 @@ export async function startHonoServer( try { const indexPath = join(rootPath, "index.html"); const html = readFileSync(indexPath, "utf-8"); - return c.html(html); + // Embed the API token so a reload at the bare URL (no + // `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates against /api/*. + // No-op when auth is dangerously omitted (empty token). The dev Vite + // plugin applies the same injection via `transformIndexHtml`. + return c.html(injectAuthToken(html, resolvedAuthToken)); } catch (error) { console.error("Error serving index.html:", error); return c.notFound(); diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 3c4225d47..4fa411ccb 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -17,6 +17,7 @@ import open from "open"; // spurious "could not resolve" warnings during build. import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts"; import { createSandboxController } from "./sandbox-controller.js"; +import { injectAuthToken } from "./inject-auth-token.js"; import type { WebServerConfig } from "./web-server-config.js"; import { webServerConfigToInitialPayload, @@ -24,8 +25,20 @@ import { } from "./web-server-config.js"; export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { + // Resolved once `configureServer` runs (createRemoteApp generates a token + // when none is supplied). Captured here so the `transformIndexHtml` hook — + // which fires per index.html request, after `configureServer` — can embed it + // into the served page. Stays "" when auth is dangerously omitted or under + // Vitest (where `configureServer` returns early), making injection a no-op. + let resolvedAuthToken = ""; return { name: "hono-api-middleware", + // Embed the API token into the dev-served index.html so a reload at the + // bare URL (no `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates. The + // prod server applies the same injection in `server.ts`. + transformIndexHtml(html) { + return injectAuthToken(html, resolvedAuthToken); + }, // `apply: 'serve'` keeps the plugin out of `vite build`, but Vitest still // instantiates a Vite server in middleware mode (no HTTP server) for // transforms and invokes `configureServer` regardless. Returning early @@ -67,6 +80,10 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { initialConfig: webServerConfigToInitialPayload(config), }); + // Expose the resolved token to `transformIndexHtml`. Left empty when + // auth is dangerously omitted so the page carries no token global. + resolvedAuthToken = config.dangerouslyOmitAuth ? "" : resolvedToken; + // Chain the API close (mcp.json watcher) and the sandbox into the // Vite server's close so dev-server restarts release both resources. const originalClose = server.close.bind(server); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 9b07497cb..13067ba06 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -17,7 +17,10 @@ import type { ServerEntry, ServerType, } from "@inspector/core/mcp/types.js"; -import { API_SERVER_ENV_VARS } from "@inspector/core/mcp/remote/constants.js"; +import { + API_SERVER_ENV_VARS, + INSPECTOR_API_TOKEN_GLOBAL, +} from "@inspector/core/mcp/remote/constants.js"; import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js"; import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js"; import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js"; @@ -65,15 +68,28 @@ const redirectUrlProvider: RedirectUrlProvider = { getRedirectUrl: () => `${window.location.origin}/oauth/callback`, }; -// Pull the dev-backend's auth token off the URL the launcher banner prints. -// `npm run dev` opens `http://localhost:6274?MCP_INSPECTOR_API_TOKEN=…`; -// every browser request to /api/* needs the same token in the -// `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401. -// Persist to sessionStorage so SPA navigations / OAuth round-trips don't -// drop the token from the URL bar. +// Recover the backend's auth token. Every browser request to /api/* needs it +// in the `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401. +// Three sources, in priority order: +// 1. `window.__INSPECTOR_API_TOKEN__` — injected into index.html by the +// backend on every page load (dev Vite plugin + prod Hono server). This +// is the robust path: it survives a bare-URL reload, a bookmark, or a +// cleared sessionStorage, none of which carry the query string. +// 2. `?MCP_INSPECTOR_API_TOKEN=…` — the URL the launcher banner prints. Kept +// as a fallback for pasted full URLs and older integrations. +// 3. sessionStorage — backstop for SPA navigations / OAuth round-trips that +// land without either of the above. +// The URL value is persisted to sessionStorage so a later navigation that +// drops it from the bar still authenticates. function getAuthToken(): string | undefined { if (typeof window === "undefined") return undefined; const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN; + const fromGlobal = (window as unknown as Record)[ + INSPECTOR_API_TOKEN_GLOBAL + ]; + if (typeof fromGlobal === "string" && fromGlobal) { + return fromGlobal; + } const params = new URLSearchParams(window.location.search); const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN); if (fromUrl) { diff --git a/clients/web/src/test/integration/server/inject-auth-token.test.ts b/clients/web/src/test/integration/server/inject-auth-token.test.ts new file mode 100644 index 000000000..ec2265e68 --- /dev/null +++ b/clients/web/src/test/integration/server/inject-auth-token.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { injectAuthToken } from "../../../../server/inject-auth-token.js"; +import { INSPECTOR_API_TOKEN_GLOBAL } from "../../../../../../core/mcp/remote/constants.js"; + +const TOKEN = "deadbeefcafef00d"; +const scriptFor = (token: string) => + `window.${INSPECTOR_API_TOKEN_GLOBAL} = ${JSON.stringify(token)};`; + +describe("injectAuthToken", () => { + it("injects the token global just before ", () => { + const html = "X"; + const out = injectAuthToken(html, TOKEN); + expect(out).toContain(scriptFor(TOKEN)); + // The script must land inside , ahead of the closing tag. + const scriptIdx = out.indexOf(scriptFor(TOKEN)); + const headCloseIdx = out.indexOf(""); + expect(scriptIdx).toBeLessThan(headCloseIdx); + expect(scriptIdx).toBeGreaterThan(out.indexOf("")); + }); + + it("falls back to before when there is no ", () => { + const html = "
"; + const out = injectAuthToken(html, TOKEN); + const scriptIdx = out.indexOf(scriptFor(TOKEN)); + expect(scriptIdx).toBeGreaterThan(-1); + expect(scriptIdx).toBeLessThan(out.indexOf("")); + }); + + it("prepends when there is neither nor ", () => { + const html = "
"; + const out = injectAuthToken(html, TOKEN); + expect(out.startsWith(``)).toBe(true); + expect(out.endsWith(html)).toBe(true); + }); + + it("returns the html untouched for an empty token (auth disabled)", () => { + const html = ""; + expect(injectAuthToken(html, "")).toBe(html); + }); + + it("escapes a token containing so the tag can't close early", () => { + const evil = "abc"; + const out = injectAuthToken("", evil); + // The raw, unescaped sequence must not survive into the output. + expect(out).not.toContain("`), + ); + expect(match).not.toBeNull(); + const parsed = JSON.parse( + (match as RegExpMatchArray)[1].replace(/\\u003c/g, "<"), + ); + expect(parsed).toBe(TOKEN); + }); +}); diff --git a/clients/web/src/test/integration/server/server-token-injection.test.ts b/clients/web/src/test/integration/server/server-token-injection.test.ts new file mode 100644 index 000000000..c2e4f9b20 --- /dev/null +++ b/clients/web/src/test/integration/server/server-token-injection.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer } from "node:net"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startHonoServer } from "../../../../server/server.js"; +import type { WebServerConfig } from "../../../../server/web-server-config.js"; +import type { WebServerHandle } from "../../../../server/types.js"; +import { INSPECTOR_API_TOKEN_GLOBAL } from "../../../../../../core/mcp/remote/constants.js"; + +// Ask the OS for an ephemeral port, then release it for the server to claim. +// There's a vanishingly small reuse window between close and re-bind, but it's +// the standard pattern for "start a real server on a free port" in tests and +// far less flaky than hard-coding one. +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (addr && typeof addr === "object") { + const { port } = addr; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error("Could not resolve a free port"))); + } + }); + }); +} + +// Pull the injected token back out of the served index.html the same way the +// browser would — read `window.__INSPECTOR_API_TOKEN__ = "…"`. +function tokenFromHtml(html: string): string | undefined { + const match = html.match( + new RegExp(`window\\.${INSPECTOR_API_TOKEN_GLOBAL} = (.+?);`), + ); + if (!match) return undefined; + return JSON.parse(match[1].replace(/\\u003c/g, "<")) as string; +} + +const TOKEN = "test-injected-token-1234567890"; +const INDEX_HTML = + "Inspector" + + '
'; + +describe("startHonoServer index.html token injection (/ -> /api/*)", () => { + let handle: WebServerHandle; + let baseUrl: string; + let staticRoot: string; + + beforeAll(async () => { + staticRoot = await mkdtemp(join(tmpdir(), "inspector-inject-")); + await writeFile(join(staticRoot, "index.html"), INDEX_HTML, "utf-8"); + + const port = await findFreePort(); + baseUrl = `http://127.0.0.1:${port}`; + const config: WebServerConfig = { + port, + hostname: "127.0.0.1", + authToken: TOKEN, + dangerouslyOmitAuth: false, + initialMcpConfig: null, + storageDir: undefined, + // Allow the same-origin requests the test issues below. + allowedOrigins: [baseUrl], + sandboxPort: 0, + sandboxHost: "127.0.0.1", + logger: undefined, + autoOpen: false, + staticRoot, + }; + handle = await startHonoServer(config); + }); + + afterAll(async () => { + await handle?.close(); + if (staticRoot) await rm(staticRoot, { recursive: true, force: true }); + }); + + it("embeds the auth token in the page served at /", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(tokenFromHtml(html)).toBe(TOKEN); + }); + + it("the embedded token authenticates a subsequent /api/* request", async () => { + const indexRes = await fetch(`${baseUrl}/`); + const token = tokenFromHtml(await indexRes.text()); + expect(token).toBe(TOKEN); + + const apiRes = await fetch(`${baseUrl}/api/config`, { + headers: { "x-mcp-remote-auth": `Bearer ${token}` }, + }); + expect(apiRes.status).toBe(200); + }); + + it("rejects an /api/* request that omits the token (proving injection is what unblocks the flow)", async () => { + const apiRes = await fetch(`${baseUrl}/api/config`); + expect(apiRes.status).toBe(401); + }); +}); diff --git a/core/mcp/remote/constants.ts b/core/mcp/remote/constants.ts index 05dbb0137..2cae9cd88 100644 --- a/core/mcp/remote/constants.ts +++ b/core/mcp/remote/constants.ts @@ -12,3 +12,14 @@ export const API_SERVER_ENV_VARS = { */ AUTH_TOKEN: "MCP_INSPECTOR_API_TOKEN", } as const; + +/** + * Name of the global property the web backend injects into `index.html` so the + * browser can recover the API token without depending on the + * `?MCP_INSPECTOR_API_TOKEN=…` query string surviving navigation (or a manual + * reload at the bare URL). The dev Vite plugin and the prod Hono server both + * embed `` on the served + * page; `App.tsx`'s `getAuthToken()` reads it ahead of the URL / sessionStorage + * fallbacks. See `clients/web/server/inject-auth-token.ts`. + */ +export const INSPECTOR_API_TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__"; From 025f06af19e6b1d0152c0a2f9f0b2bf5fb328ddb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 30 May 2026 20:01:28 -0400 Subject: [PATCH 2/2] fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review feedback on the token-injection PR: - Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously served the raw index.html off disk via serveStatic, bypassing injection — so a bookmark/reload at a non-`/` route with empty sessionStorage would 401. Route the SPA fallback through the same `serveIndexHtml` (inject) helper; real static assets (paths with a dot) still serve verbatim. Dev already injected on every HTML serve via Vite `transformIndexHtml`. - `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__` to sessionStorage (not just the URL-param branch), priming the backstop for any later navigation that loses the global. - Injected HTML responses now send `Cache-Control: no-store`, so a page carrying a token isn't cached and served stale after a restart regenerates the token. Integration tests added: SPA fallback (`/oauth/callback`) carries the token, `Cache-Control: no-store` on injected HTML, real assets served verbatim, and unknown `/api` routes 404 rather than falling through to the HTML shell. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/server/server.ts | 53 ++++++++++++------- clients/web/src/App.tsx | 24 +++++---- .../server/server-token-injection.test.ts | 41 ++++++++++++++ 3 files changed, 90 insertions(+), 28 deletions(-) diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index 0f47a59fb..4d6aac4c5 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -11,6 +11,7 @@ import open from "open"; import { serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Hono } from "hono"; +import type { Context } from "hono"; import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts"; import { createSandboxController } from "./sandbox-controller.js"; import { injectAuthToken } from "./inject-auth-token.js"; @@ -61,33 +62,47 @@ export async function startHonoServer( return apiApp.fetch(c.req.raw); }); + // Serve index.html with the API token injected so a reload at any bare URL + // (no `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates against /api/*. + // No-op when auth is dangerously omitted (empty token). The dev Vite plugin + // applies the same injection via `transformIndexHtml`. `Cache-Control: + // no-store` keeps a browser/proxy from serving a page that carries a stale + // token after a server restart regenerates it (randomBytes per start). + const serveIndexHtml = (c: Context) => { + const indexPath = join(rootPath, "index.html"); + const html = readFileSync(indexPath, "utf-8"); + c.header("Cache-Control", "no-store"); + return c.html(injectAuthToken(html, resolvedAuthToken)); + }; + app.get("/", async (c) => { try { - const indexPath = join(rootPath, "index.html"); - const html = readFileSync(indexPath, "utf-8"); - // Embed the API token so a reload at the bare URL (no - // `?MCP_INSPECTOR_API_TOKEN=…`) still authenticates against /api/*. - // No-op when auth is dangerously omitted (empty token). The dev Vite - // plugin applies the same injection via `transformIndexHtml`. - return c.html(injectAuthToken(html, resolvedAuthToken)); + return serveIndexHtml(c); } catch (error) { console.error("Error serving index.html:", error); return c.notFound(); } }); - app.use( - "/*", - serveStatic({ - root: rootPath, - rewriteRequestPath: (path) => { - if (!path.includes(".") && !path.startsWith("/api")) { - return "/index.html"; - } - return path; - }, - }), - ); + // Real static assets (paths with a file extension). Missing files fall + // through to the SPA fallback below via `next()`. + app.use("/*", serveStatic({ root: rootPath })); + + // SPA deep-link fallback: any non-/api route that didn't resolve to a static + // asset (e.g. `/oauth/callback`, the OAuth landing URL) serves the *injected* + // index.html — not the raw file — so bookmarks and hand-typed reloads at + // those paths get the token global too, matching the `/` route. + app.get("*", async (c) => { + if (c.req.path.startsWith("/api")) { + return c.notFound(); + } + try { + return serveIndexHtml(c); + } catch (error) { + console.error("Error serving index.html:", error); + return c.notFound(); + } + }); const httpServer = serve( { diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 13067ba06..044e2e961 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -79,27 +79,33 @@ const redirectUrlProvider: RedirectUrlProvider = { // as a fallback for pasted full URLs and older integrations. // 3. sessionStorage — backstop for SPA navigations / OAuth round-trips that // land without either of the above. -// The URL value is persisted to sessionStorage so a later navigation that -// drops it from the bar still authenticates. +// Both the injected global and the URL value are persisted to sessionStorage +// so a later navigation that drops them (e.g. a deep-link load that wasn't +// injected, or an iframe) still authenticates from the backstop. function getAuthToken(): string | undefined { if (typeof window === "undefined") return undefined; const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN; + // Best-effort persistence — sessionStorage may be unavailable (privacy + // mode, iframe sandboxing, etc.); the resolved value still works for the + // current page load regardless. + const persist = (token: string): void => { + try { + window.sessionStorage.setItem(STORAGE_KEY, token); + } catch { + // ignore — see note above + } + }; const fromGlobal = (window as unknown as Record)[ INSPECTOR_API_TOKEN_GLOBAL ]; if (typeof fromGlobal === "string" && fromGlobal) { + persist(fromGlobal); return fromGlobal; } const params = new URLSearchParams(window.location.search); const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN); if (fromUrl) { - try { - window.sessionStorage.setItem(STORAGE_KEY, fromUrl); - } catch { - // Best-effort persistence — sessionStorage may be unavailable - // (privacy mode, iframe sandboxing, etc.); the URL value still - // works for the current page load. - } + persist(fromUrl); return fromUrl; } try { diff --git a/clients/web/src/test/integration/server/server-token-injection.test.ts b/clients/web/src/test/integration/server/server-token-injection.test.ts index c2e4f9b20..7e7992f30 100644 --- a/clients/web/src/test/integration/server/server-token-injection.test.ts +++ b/clients/web/src/test/integration/server/server-token-injection.test.ts @@ -44,6 +44,8 @@ const INDEX_HTML = "Inspector" + '
'; +const ASSET_JS = 'console.log("static asset");\n'; + describe("startHonoServer index.html token injection (/ -> /api/*)", () => { let handle: WebServerHandle; let baseUrl: string; @@ -52,6 +54,9 @@ describe("startHonoServer index.html token injection (/ -> /api/*)", () => { beforeAll(async () => { staticRoot = await mkdtemp(join(tmpdir(), "inspector-inject-")); await writeFile(join(staticRoot, "index.html"), INDEX_HTML, "utf-8"); + // A real static asset (path has a file extension) to prove serveStatic + // still serves files verbatim rather than routing them through injection. + await writeFile(join(staticRoot, "asset.js"), ASSET_JS, "utf-8"); const port = await findFreePort(); baseUrl = `http://127.0.0.1:${port}`; @@ -100,4 +105,40 @@ describe("startHonoServer index.html token injection (/ -> /api/*)", () => { const apiRes = await fetch(`${baseUrl}/api/config`); expect(apiRes.status).toBe(401); }); + + it("injects the token into the SPA deep-link fallback (e.g. /oauth/callback)", async () => { + // A non-/api path with no file extension resolves to the SPA fallback, + // which must serve the *injected* index.html — not a raw file — so a + // bookmark or reload at the OAuth callback URL still authenticates. + const res = await fetch(`${baseUrl}/oauth/callback?code=abc&state=xyz`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(tokenFromHtml(html)).toBe(TOKEN); + }); + + it("sets Cache-Control: no-store on injected HTML so a restart's new token isn't served stale", async () => { + const root = await fetch(`${baseUrl}/`); + expect(root.headers.get("cache-control")).toBe("no-store"); + const fallback = await fetch(`${baseUrl}/oauth/callback`); + expect(fallback.headers.get("cache-control")).toBe("no-store"); + }); + + it("serves real static assets verbatim (no token injection)", async () => { + const res = await fetch(`${baseUrl}/asset.js`); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toBe(ASSET_JS); + expect(body).not.toContain(INSPECTOR_API_TOKEN_GLOBAL); + }); + + it("does not route /api paths through the SPA fallback", async () => { + // An unknown /api route must 404 as JSON-less notFound, never the HTML + // shell (which would mask real API errors behind a 200 page). + const res = await fetch(`${baseUrl}/api/does-not-exist`, { + headers: { "x-mcp-remote-auth": `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(404); + const body = await res.text(); + expect(body).not.toContain(INSPECTOR_API_TOKEN_GLOBAL); + }); });