Skip to content

Commit 19b2e24

Browse files
committed
fix(local,cli,react): replace token-in-URL bootstrap with a one-time-code exchange
1 parent fff7ed6 commit 19b2e24

8 files changed

Lines changed: 442 additions & 31 deletions

File tree

.changeset/otc-bootstrap.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@executor-js/local-app": patch
3+
"@executor-js/cli": patch
4+
"@executor-js/react": patch
5+
---
6+
7+
fix: replace the token-in-URL web bootstrap with a one-time-code exchange
8+
9+
Opening the web UI previously put the daemon bearer token in the URL
10+
(`?_token=<token>`) and the SPA persisted it to localStorage — both are
11+
leak-prone surfaces (browser history, logs, screen recordings, and
12+
localStorage is readable by any script on the origin).
13+
14+
`executor web` / `executor open` now mint a one-time code (bearer-gated,
15+
single-use, 60-second TTL, 128-bit entropy, bound to the running daemon
16+
instance) and open `/?_otc=<code>`. On first load the SPA exchanges the
17+
code for the bearer, applies it to the in-memory connection, and strips the
18+
query. The server also sets an HttpOnly SameSite=strict cookie as transport
19+
hardening. Nothing is written to localStorage by the bootstrap path; the
20+
legacy `?_token=` query is still accepted for compatibility with older
21+
daemons but is no longer persisted.

apps/cli/src/main.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,7 +1149,10 @@ const runForegroundSession = (input: {
11491149

11501150
try {
11511151
console.log(`Executor is ready.`);
1152-
console.log(`Open: ${baseUrl}/?_token=${server.authToken}`);
1152+
const otcCode = server.otcStore?.issue() ?? null;
1153+
console.log(
1154+
`Open: ${otcCode ? `${baseUrl}/?_otc=${otcCode}` : `${baseUrl}/?_token=${server.authToken}`}`,
1155+
);
11531156
console.log(`Web: ${baseUrl}`);
11541157
console.log(`MCP: ${baseUrl}/mcp`);
11551158
console.log(`OpenAPI: ${baseUrl}/api/docs`);
@@ -3269,11 +3272,37 @@ const openRunningLocalWebApp = (): Effect.Effect<
32693272
}
32703273
const { origin, auth } = manifest.connection;
32713274
const token = auth?.kind === "bearer" ? auth.token : undefined;
3272-
const url = token ? `${origin}/?_token=${token}` : origin;
3275+
if (!token) {
3276+
console.log(`Opening ${origin}`);
3277+
yield* openInBrowser(origin);
3278+
return;
3279+
}
3280+
// Mint a one-time bootstrap code instead of putting the bearer in the
3281+
// URL. The browser exchanges it for the bearer on first load (HttpOnly
3282+
// cookie + in-memory connection), and the query is stripped.
3283+
const otc = yield* mintOtcForDaemon(origin, token);
3284+
const url = otc ? `${origin}/?_otc=${otc}` : `${origin}/?_token=${token}`;
32733285
console.log(`Opening ${url}`);
32743286
yield* openInBrowser(url);
32753287
});
32763288

3289+
/** Mint a one-time bootstrap code from the running daemon (bearer-gated).
3290+
* Falls back to null on any failure — the caller then falls back to the
3291+
* legacy `?_token=` URL rather than failing the open. */
3292+
const mintOtcForDaemon = (origin: string, token: string): Effect.Effect<string | null> =>
3293+
Effect.tryPromise({
3294+
try: async () => {
3295+
const res = await fetch(`${origin}/api/auth/otc`, {
3296+
method: "POST",
3297+
headers: { authorization: `Bearer ${token}` },
3298+
});
3299+
if (!res.ok) return null;
3300+
const body = (await res.json()) as { readonly code?: unknown };
3301+
return typeof body.code === "string" && body.code.length > 0 ? body.code : null;
3302+
},
3303+
catch: () => null,
3304+
}).pipe(Effect.catch(() => Effect.succeed(null)));
3305+
32773306
/**
32783307
* `executor open` — the friendly way back in. Reads the running local server's
32793308
* manifest and opens the browser straight to its `?_token=` URL, so the user
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import { startServer, type ServerInstance } from "./serve";
7+
import { OTC_TTL_MS, makeOtcStore } from "./otc";
8+
9+
let clientDir: string;
10+
let dataDir: string;
11+
let server: ServerInstance | null = null;
12+
13+
const TOKEN = "test-bearer-token";
14+
15+
const testHandlers = () => ({
16+
api: {
17+
handler: async () => new Response("ok"),
18+
dispose: async () => {},
19+
},
20+
mcp: {
21+
handleRequest: async () => new Response("ok"),
22+
handleApprovalRequest: async () => new Response("ok"),
23+
handlePausedRequest: async () => new Response("ok"),
24+
close: async () => {},
25+
},
26+
});
27+
28+
const startTestServer = async (): Promise<string> => {
29+
server = await startServer({
30+
port: 0,
31+
hostname: "127.0.0.1",
32+
clientDir,
33+
authToken: TOKEN,
34+
handlers: testHandlers(),
35+
});
36+
return `http://127.0.0.1:${server.port}`;
37+
};
38+
39+
beforeEach(() => {
40+
clientDir = mkdtempSync(join(tmpdir(), "exec-otc-serve-"));
41+
dataDir = mkdtempSync(join(tmpdir(), "exec-otc-data-"));
42+
process.env.EXECUTOR_DATA_DIR = dataDir;
43+
process.env.EXECUTOR_SCOPE_DIR = dataDir;
44+
writeFileSync(
45+
join(clientDir, "index.html"),
46+
"<!doctype html><html><body>index-shell</body></html>",
47+
);
48+
});
49+
50+
afterEach(async () => {
51+
if (server) {
52+
await server.stop();
53+
server = null;
54+
}
55+
delete process.env.EXECUTOR_DATA_DIR;
56+
delete process.env.EXECUTOR_SCOPE_DIR;
57+
rmSync(clientDir, { recursive: true, force: true });
58+
rmSync(dataDir, { recursive: true, force: true });
59+
});
60+
61+
describe("OTC exchange endpoint", () => {
62+
it("mints a code via the bearer-gated route and exchanges it once (200 + HttpOnly cookie)", async () => {
63+
const origin = await startTestServer();
64+
const mint = await fetch(`${origin}/api/auth/otc`, {
65+
method: "POST",
66+
headers: { authorization: `Bearer ${TOKEN}` },
67+
});
68+
expect(mint.status).toBe(200);
69+
const { code } = (await mint.json()) as { code: string };
70+
expect(code.length).toBeGreaterThanOrEqual(16); // ≥128 bits base64url
71+
72+
const exchange = await fetch(`${origin}/api/auth/exchange`, {
73+
method: "POST",
74+
headers: { "content-type": "application/x-www-form-urlencoded" },
75+
body: `code=${encodeURIComponent(code)}`,
76+
});
77+
expect(exchange.status).toBe(200);
78+
const body = (await exchange.json()) as { token: string };
79+
expect(body.token).toBe(TOKEN);
80+
81+
const setCookie = exchange.headers.get("set-cookie") ?? "";
82+
expect(setCookie).toContain("executor_session");
83+
expect(setCookie).toContain("HttpOnly");
84+
expect(setCookie).toContain("SameSite=Strict");
85+
});
86+
87+
it("rejects a replayed code (single-use — second exchange is 400)", async () => {
88+
const origin = await startTestServer();
89+
const mint = await fetch(`${origin}/api/auth/otc`, {
90+
method: "POST",
91+
headers: { authorization: `Bearer ${TOKEN}` },
92+
});
93+
const { code } = (await mint.json()) as { code: string };
94+
95+
const first = await fetch(`${origin}/api/auth/exchange`, {
96+
method: "POST",
97+
headers: { "content-type": "application/x-www-form-urlencoded" },
98+
body: `code=${encodeURIComponent(code)}`,
99+
});
100+
expect(first.status).toBe(200);
101+
102+
const replay = await fetch(`${origin}/api/auth/exchange`, {
103+
method: "POST",
104+
headers: { "content-type": "application/x-www-form-urlencoded" },
105+
body: `code=${encodeURIComponent(code)}`,
106+
});
107+
expect(replay.status).toBe(400);
108+
});
109+
110+
it("rejects an unknown code", async () => {
111+
const origin = await startTestServer();
112+
const res = await fetch(`${origin}/api/auth/exchange`, {
113+
method: "POST",
114+
headers: { "content-type": "application/x-www-form-urlencoded" },
115+
body: "code=never-issued",
116+
});
117+
expect(res.status).toBe(400);
118+
});
119+
120+
it("rejects the mint route without a bearer", async () => {
121+
const origin = await startTestServer();
122+
const res = await fetch(`${origin}/api/auth/otc`, { method: "POST" });
123+
expect(res.status).toBe(401);
124+
});
125+
126+
it("rejects an expired code (TTL honored by the store)", () => {
127+
let now = 1_000;
128+
const store = makeOtcStore(() => now);
129+
const code = store.issue();
130+
expect(store.consume(code)).toBe(code);
131+
132+
// Re-issue after expiry — the consumed code must stay dead even after
133+
// pruning.
134+
const code2 = store.issue();
135+
now = now + OTC_TTL_MS + 1;
136+
expect(store.consume(code2)).toBeNull();
137+
expect(store.consume(code)).toBeNull();
138+
});
139+
});

apps/local/src/otc.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// ---------------------------------------------------------------------------
2+
// OtcStore — one-time codes for the web bootstrap exchange.
3+
//
4+
// The local daemon's bearer token is the single credential gating every
5+
// surface. The web bootstrap previously shipped it in the URL (`?_token=`)
6+
// and persisted it to localStorage — both are XSS/leak-adjacent surfaces
7+
// (browser history, logs, screen recordings, localStorage read by any script
8+
// on the origin). The OTC flow replaces the URL-token with a one-time code:
9+
//
10+
// 1. `executor web` / `executor open` mints a code from the running daemon
11+
// (bearer-gated endpoint; the CLI already holds the bearer).
12+
// 2. The browser loads `/?_otc=<code>`, POSTs it to the unauthenticated
13+
// `/api/auth/exchange` endpoint, and receives the bearer in the response
14+
// body PLUS an HttpOnly SameSite=strict cookie (transport hardening —
15+
// the cookie is not the request gate, the bearer is; see serve-shared
16+
// makeIsAuthorized).
17+
// 3. The client applies the bearer to the in-memory connection and strips
18+
// the query. Nothing is written to localStorage.
19+
//
20+
// Codes are single-use, TTL-bounded (≤60s), high-entropy (≥128 bits), bound
21+
// to the daemon instance (the in-memory map dies with the process, so a code
22+
// can never be replayed against a future daemon generation), and never
23+
// logged.
24+
// ---------------------------------------------------------------------------
25+
26+
import { randomBytes } from "node:crypto";
27+
28+
export const OTC_TTL_MS = 60 * 1000;
29+
const OTC_ENTROPY_BYTES = 16; // 128 bits
30+
31+
interface OtcEntry {
32+
readonly code: string;
33+
readonly expiresAt: number;
34+
}
35+
36+
export interface OtcStore {
37+
/** Mint a single-use code valid for OTC_TTL_MS. */
38+
readonly issue: () => string;
39+
/**
40+
* Consume a code. Returns the code's id on success (after which the code is
41+
* dead), or null if the code is unknown, already consumed, or expired.
42+
* Consumption is destructive: a consumed code can never be redeemed again.
43+
*/
44+
readonly consume: (code: string) => string | null;
45+
}
46+
47+
/** In-memory OTC store. Instance-bound by construction. */
48+
export const makeOtcStore = (now: () => number = Date.now): OtcStore => {
49+
const codes = new Map<string, OtcEntry>();
50+
51+
const pruneExpired = (): void => {
52+
const t = now();
53+
for (const [code, entry] of codes) {
54+
if (entry.expiresAt <= t) codes.delete(code);
55+
}
56+
};
57+
58+
return {
59+
issue: () => {
60+
pruneExpired();
61+
// Collision odds are negligible at 128 bits, but loop anyway so a
62+
// pathological collision can never silently clobber a live code.
63+
let code = randomBytes(OTC_ENTROPY_BYTES).toString("base64url");
64+
while (codes.has(code)) {
65+
code = randomBytes(OTC_ENTROPY_BYTES).toString("base64url");
66+
}
67+
codes.set(code, { code, expiresAt: now() + OTC_TTL_MS });
68+
return code;
69+
},
70+
71+
consume: (code) => {
72+
pruneExpired();
73+
const entry = codes.get(code);
74+
if (entry === undefined) return null;
75+
codes.delete(code);
76+
return entry.expiresAt > now() ? entry.code : null;
77+
},
78+
};
79+
};

apps/local/src/serve.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { Subprocess } from "bun";
1313
import { setOAuthCompletionListener } from "@executor-js/api";
1414
import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
1515
import { loadOrMintLocalAuthToken } from "./auth";
16+
import { makeOtcStore, type OtcStore } from "./otc";
1617
import { publishOAuthResult, waitForOAuthResult } from "./oauth-result-store";
1718
import { disposeAnalytics } from "./analytics";
1819
import { startIntegrationsRefresh } from "./integrations";
@@ -276,6 +277,8 @@ export interface ServerInstance {
276277
/** The effective bearer token this server validates. Callers publish it in the
277278
* manifest, print the `?_token=` bootstrap URL, and hand it to MCP clients. */
278279
authToken: string;
280+
/** One-time bootstrap-code store for the web OTC exchange. */
281+
otcStore: OtcStore;
279282
stop: () => Promise<void>;
280283
}
281284

@@ -338,6 +341,9 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
338341
// process could otherwise drive.
339342
const authToken = normalizeCredential(opts.authToken) ?? loadOrMintLocalAuthToken();
340343
const isAuthorized = makeIsAuthorized(authToken);
344+
// One-time bootstrap codes (web OTC exchange). Instance-bound: dies with
345+
// the process, so a code can never be replayed against a future daemon.
346+
const otcStore = makeOtcStore();
341347
// CORS-only origin allowlist (no Host gate — the bearer is the boundary).
342348
const corsAllowedHosts = new Set<string>([
343349
...DEFAULT_ALLOWED_HOSTS,
@@ -425,6 +431,46 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
425431
return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));
426432
}
427433

434+
// The OTC mint is bearer-gated (the CLI holds the bearer from the
435+
// manifest); the exchange is reached by the browser on FIRST load,
436+
// before it has any bearer — same rationale as the OAuth callback: an
437+
// external actor (here, the just-opened browser tab) cannot carry our
438+
// bearer. The one-time code IS the credential; consumption is
439+
// destructive.
440+
if (url.pathname === "/api/auth/otc" && req.method === "POST") {
441+
if (!isAuthorized(req)) {
442+
return withCors(new Response("Unauthorized", { status: 401 }));
443+
}
444+
return withCors(
445+
new Response(JSON.stringify({ code: otcStore.issue() }), {
446+
status: 200,
447+
headers: { "content-type": "application/json" },
448+
}),
449+
);
450+
}
451+
if (url.pathname === "/api/auth/exchange" && req.method === "POST") {
452+
// oxlint-disable-next-line executor/no-promise-catch -- boundary: raw web-handler request body read; an unreadable body collapses to no code, which the exchange rejects
453+
const code = (await req.text().catch(() => ""))
454+
.split("&")
455+
.find((kv) => kv.startsWith("code="))
456+
?.slice("code=".length);
457+
const redeemed = code ? otcStore.consume(code) : null;
458+
if (redeemed === null) {
459+
return withCors(new Response("Invalid or expired code", { status: 400 }));
460+
}
461+
// The bearer is the request gate for /api; the HttpOnly cookie is
462+
// transport hardening (SameSite=strict, never readable by JS).
463+
return withCors(
464+
new Response(JSON.stringify({ token: authToken }), {
465+
status: 200,
466+
headers: {
467+
"content-type": "application/json",
468+
"set-cookie": `executor_session=${authToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800`,
469+
},
470+
}),
471+
);
472+
}
473+
428474
// OAuth callbacks and CIMD documents are reached by the external
429475
// provider, which cannot carry our local bearer. Everything else under
430476
// /api and /mcp requires the bearer.
@@ -524,6 +570,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
524570
return {
525571
port: server.port!,
526572
authToken,
573+
otcStore,
527574
async stop() {
528575
if (stopped) return;
529576
stopped = true;
@@ -540,5 +587,6 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
540587

541588
if (import.meta.main) {
542589
const server = await startServer();
543-
console.log(`Executor listening on http://localhost:${server.port}/?_token=${server.authToken}`);
590+
const otc = server.otcStore.issue();
591+
console.log(`Executor listening on http://localhost:${server.port}/?_otc=${otc}`);
544592
}

0 commit comments

Comments
 (0)