a2a-exposed 0.7.9 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/main.mjs CHANGED
@@ -126,7 +126,11 @@ Pairing (OAuth 2.0 device flow, RFC 8628: agents connect without pasting tokens
126
126
  peer then swaps the old token for the new one under the same label (older peers
127
127
  keep the old token until their owner revokes it). --card-url: the card to name in
128
128
  the request (default: this deployment's); a *.ts.net / LAN card is sent as
129
- informational (flagged "not publicly reachable"), a non-https one is left out
129
+ informational (flagged "not publicly reachable"), a non-https one is left out.
130
+ The name sent is the card named in the request (--card-url, otherwise this
131
+ deployment's). --name overrides it. If that card can't be read, the name is the
132
+ configured agent name, the worker name, or the public host's first label. The
133
+ other inbox labels the token from that name and adds -2, -3, … when it is taken
130
134
  pair set-password --web [--ttl MIN] [--json]
131
135
  one-time link (default 15 min, single use) where your human sets or changes the
132
136
  approval password on a web page. Send it to them privately; never open or fill it
@@ -179,6 +183,8 @@ Control plane account (only when that deployment's login is on)
179
183
  page. Creating an agent shows the owner token once. That page is not stored.
180
184
  FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
181
185
  Unset, there is no limit. The database rejects another insert.
186
+ SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
187
+ A refusal returns to /app?error=invite and the page says Sign-in is invite-only for now.
182
188
  The control Worker logs social_sign_in_failed before the
183
189
  provider page, social_sign_in_timeout when that sign-in does not
184
190
  return, and oauth_callback_failed when the provider sends the browser
package/lib/pair.mjs CHANGED
@@ -193,6 +193,39 @@ export function deviceEndpointsFromCard(card) {
193
193
  const slug = (s) => String(s || "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/, "").toLowerCase();
194
194
  const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000 * Number(process.env.A2A_POLL_SCALE || 1)));
195
195
 
196
+ /** First DNS label of this deployment's public URL, or "" for loopback and an unusable host. */
197
+ export function hostAlias(ownUrl) {
198
+ if (!ownUrl) return "";
199
+ try {
200
+ const host = new URL(ownUrl).hostname;
201
+ if (host === "localhost" || host === "127.0.0.1" || host === "[::1]") return "";
202
+ return slug(host.split(".")[0]);
203
+ } catch { return ""; }
204
+ }
205
+
206
+ /**
207
+ * client_name for a pairing request. An explicit `--name` wins, then the card named in the request
208
+ * (`--card-url`, otherwise this deployment's card), then the configured agent name, then the local
209
+ * alias (worker name, else the public host's first label).
210
+ */
211
+ export function pairingClientName({ explicit = "", cardName = "", agentName = "", workerName = "", ownUrl = "" } = {}) {
212
+ const text = (v) => String(v || "").replace(/\s+/g, " ").trim().slice(0, 64);
213
+ for (const v of [explicit, cardName, agentName, workerName, hostAlias(ownUrl)]) {
214
+ const t = text(v);
215
+ if (t) return t;
216
+ }
217
+ return "a2a-exposed agent";
218
+ }
219
+
220
+ /** Name on a card URL, or "" when the card can't be read. */
221
+ async function readCardName(url) {
222
+ if (!url) return "";
223
+ try {
224
+ const { status, data } = await httpJson(url, { timeout: 3000 });
225
+ return status === 200 && data && typeof data.name === "string" ? data.name : "";
226
+ } catch { return ""; }
227
+ }
228
+
196
229
  async function postForm(url, params, bearer = null) {
197
230
  let r;
198
231
  const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
@@ -315,16 +348,19 @@ A different agent at the same URL? Pass another --alias.`);
315
348
  `Resuming the pairing request to ${peerName} (code ${st.userCode}, link ${st.verificationUriComplete}).`);
316
349
  else {
317
350
  const own = baseUrl();
318
- const params = { client_name: o.name || C.get("A2A_AGENT_NAME") || C.get("A2A_WORKER_NAME") || "a2a-exposed agent", client_id: C.get("A2A_WORKER_NAME") || alias };
319
- const card = cardUrlToSend(o["card-url"], own, o.json);
320
- if (card) params.agent_card_url = card;
351
+ const sentCard = cardUrlToSend(o["card-url"], own, o.json);
352
+ // The name is the card this request actually names. --card-url (a Tailnet card, before a public
353
+ // deployment exists) wins. With no override, read this deployment's card, including an http one.
354
+ const nameUrl = sentCard || (!o["card-url"] && /^https?:\/\//.test(own || "") ? `${own.replace(/\/$/, "")}/.well-known/agent-card.json` : "");
355
+ const params = { client_name: pairingClientName({ explicit: o.name, cardName: await readCardName(nameUrl), agentName: C.get("A2A_AGENT_NAME"), workerName: C.get("A2A_WORKER_NAME"), ownUrl: own }), client_id: C.get("A2A_WORKER_NAME") || alias };
356
+ if (sentCard) params.agent_card_url = sentCard;
321
357
  // replacing: present the old token, so a peer that supports it swaps the token under the same label (no orphan)
322
358
  const r = await postForm(ep.device, params, replace && oldToken ? oldToken : null);
323
359
  if (r.status !== 200 || !r.data.device_code || !r.data.user_code) {
324
360
  const why = r.data.error_description || r.data.error || `HTTP ${r.status}`;
325
- const privateCard = !!card && (() => { try { return isPrivateHost(new URL(card).hostname); } catch { return false; } })();
361
+ const privateCard = !!sentCard && (() => { try { return isPrivateHost(new URL(sentCard).hostname); } catch { return false; } })();
326
362
  die(r.status === 429 ? `${peerName} is not accepting more pairing requests right now: ${why}` : r.status === 404 ? `${peerName} has device-flow pairing disabled (${why}); ask its owner for a token`
327
- : `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(card).host}) is not publicly reachable, and inboxes may accept only public https cards: retry with --card-url set to a public card, or expose your agent through a public façade first (setup skill: "Already have A2A on a Tailnet or LAN")` : ""}`);
363
+ : `pairing request to ${peerName} failed: ${why}${privateCard && r.status === 400 ? `. The card URL sent (${new URL(sentCard).host}) is not publicly reachable, and inboxes may accept only public https cards: retry with --card-url set to a public card, or expose your agent through a public façade first (setup skill: "Already have A2A on a Tailnet or LAN")` : ""}`);
328
364
  }
329
365
  st = { base, alias, device: ep.device, token: ep.token, deviceCode: r.data.device_code, userCode: r.data.user_code,
330
366
  verificationUri: r.data.verification_uri || "", verificationUriComplete: r.data.verification_uri_complete || r.data.verification_uri || "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "description": "Give any AI agent a public A2A (Agent2Agent) endpoint: Cloudflare Worker inbox + wake webhook or scheduled inbox checks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,18 @@ export function issuerHost(issuer: string): string {
23
23
  try { return new URL(issuer).host; } catch { return ""; }
24
24
  }
25
25
 
26
+ /**
27
+ * Origin to list in `form-action` when OpenID Connect approval is on.
28
+ * Scheme, host, and port only. A path, userinfo, or a non-https issuer is not included.
29
+ */
30
+ export function formActionOrigin(issuer: string): string {
31
+ try {
32
+ const u = new URL(issuer);
33
+ if (u.protocol !== "https:" || u.username || u.password) return "";
34
+ return u.origin;
35
+ } catch { return ""; }
36
+ }
37
+
26
38
  export function issuerBase(issuer: string): string {
27
39
  return issuer.replace(/\/+$/, "");
28
40
  }
@@ -241,6 +253,11 @@ export async function startOidc(ctx: TenantContext, o: {
241
253
  let disc: Discovery;
242
254
  try { disc = await discover(ctx, gate.issuer); }
243
255
  catch { return { ok: false, error: UNREACHABLE, reason: "discovery" }; }
256
+ // form-action lists only the issuer origin. A sign-in address on another origin would be blocked by the browser.
257
+ const authOrigin = formActionOrigin(disc.authorization_endpoint);
258
+ if (!authOrigin || authOrigin !== formActionOrigin(gate.issuer)) {
259
+ return { ok: false, error: "The identity provider's sign-in address is not on the issuer origin. Nothing was approved.", reason: "endpoint_origin" };
260
+ }
244
261
  const { verifier, challenge } = await pkce();
245
262
  const state = b64url(crypto.getRandomValues(new Uint8Array(32)));
246
263
  const nonce = b64url(crypto.getRandomValues(new Uint8Array(32)));
@@ -1325,6 +1325,16 @@ function approvalPageBits(ctx: TenantContext): { oidc?: { label: string }; passw
1325
1325
  return bits;
1326
1326
  }
1327
1327
 
1328
+ /**
1329
+ * The OpenID Connect issuer origin, when that approval method is on.
1330
+ * A form on this host that 302s to the issuer is checked against this origin by Chrome's `form-action`.
1331
+ */
1332
+ function oidcFormOrigins(ctx: TenantContext): string[] {
1333
+ if (!O.approvalPolicy(ctx.gates.approvalOidc).oidc) return [];
1334
+ const origin = O.formActionOrigin(ctx.gates.approvalOidc.issuer);
1335
+ return origin ? [origin] : [];
1336
+ }
1337
+
1328
1338
  const redirect302 = (location: string) => new Response(null, { status: 302, headers: { location, "cache-control": "no-store", pragma: "no-cache", "referrer-policy": "no-referrer" } });
1329
1339
 
1330
1340
  async function approvalPassword(ctx: TenantContext): Promise<P.PasswordRecord | null> {
@@ -1350,7 +1360,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
1350
1360
  const pw = await approvalPassword(ctx);
1351
1361
  const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
1352
1362
  const send = (status = 200) => {
1353
- const h: Record<string, string> = P.pageHeaders(nonce);
1363
+ const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
1354
1364
  h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
1355
1365
  return new Response(P.devicePage(model, nonce), { status, headers: h });
1356
1366
  };
@@ -1408,7 +1418,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
1408
1418
  if (!started.ok) {
1409
1419
  log("oidc_rejected", { kind: "device", reason: started.reason });
1410
1420
  await show(form.user_code, { kind: "error", text: started.error });
1411
- return send(started.reason === "config" ? 400 : 502);
1421
+ return send(started.reason === "config" || started.reason === "endpoint_origin" ? 400 : 502);
1412
1422
  }
1413
1423
  log("oidc_start", { kind: "device", issuer: O.issuerHost(ctx.gates.approvalOidc.issuer) });
1414
1424
  return redirect302(started.location);
@@ -1455,7 +1465,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL): P
1455
1465
  const pw = await approvalPassword(ctx);
1456
1466
  const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
1457
1467
  const send = (status = 200) => {
1458
- const h: Record<string, string> = P.pageHeaders(nonce);
1468
+ const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
1459
1469
  h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
1460
1470
  return new Response(P.devicePage(model, nonce), { status, headers: h });
1461
1471
  };
@@ -1497,7 +1507,7 @@ async function liveSetup(ctx: TenantContext, token: string): Promise<{ rec: Setu
1497
1507
  return A.timingSafeEqualStr(await A.sha256(token), s.rec.hash) ? s : null;
1498
1508
  }
1499
1509
 
1500
- /** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame, CSP and CSRF as /device. */
1510
+ /** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame and CSRF as /device. The form posts only to this host, so form-action stays 'self'. */
1501
1511
  async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
1502
1512
  const nonce = P.randomB64(), ip = clientIp(req);
1503
1513
  const csrfName = "a2a_device_csrf";
@@ -1897,10 +1907,9 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
1897
1907
  const q = req.method === "GET" ? Object.fromEntries(url.searchParams) : form;
1898
1908
  const c = await checkAuthorize(ctx, q, ip);
1899
1909
  const pw = await approvalPassword(ctx);
1900
- const page = (status: number, body: string, formOrigin?: string) => {
1901
- const h: Record<string, string> = P.pageHeaders(nonce);
1902
- // the approve/deny POST answers with a redirect to the client: form-action must allow that origin too
1903
- if (formOrigin) h["content-security-policy"] = h["content-security-policy"].replace("form-action 'self'", `form-action 'self' ${formOrigin}`);
1910
+ const page = (status: number, body: string, formOrigins: string[] = []) => {
1911
+ // Approve and deny 302 to the client. OpenID Connect 302s to the issuer. Chrome checks both against form-action.
1912
+ const h: Record<string, string> = P.pageHeaders(nonce, formOrigins);
1904
1913
  h["set-cookie"] = `${csrfName}=${csrf}; Path=/oauth/authorize; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
1905
1914
  return new Response(P.pageShell("Connect an MCP client", nonce, body), { status, headers: h });
1906
1915
  };
@@ -1909,7 +1918,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
1909
1918
  const origin = new URL(c.redirect!).origin;
1910
1919
  const show = (status: number, notice?: M.ConsentModel["notice"]) => page(status, M.consentBody({ agentName: ctx.AGENT_NAME || "this A2A inbox", cli: cliCommand(ctx),
1911
1920
  csrf, clientName: c.client!.client_name || "", clientIdHost: c.client!.cimd ? new URL(c.client!.client_id).host : undefined,
1912
- redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), origin);
1921
+ redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), [origin, ...oidcFormOrigins(ctx)]);
1913
1922
  if (req.method === "GET") return show(200);
1914
1923
  const reqOrigin = req.headers.get("origin");
1915
1924
  if ((reqOrigin && reqOrigin !== "null" && reqOrigin !== new URL(ctx.PUBLIC_URL).origin && reqOrigin !== url.origin) || fresh || !form.csrf || !A.timingSafeEqualStr(form.csrf, csrf))
@@ -1925,7 +1934,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
1925
1934
  });
1926
1935
  if (!started.ok) {
1927
1936
  log("oidc_rejected", { kind: "mcp", reason: started.reason });
1928
- return show(started.reason === "config" ? 400 : 502, { kind: "error", text: started.error });
1937
+ return show(started.reason === "config" || started.reason === "endpoint_origin" ? 400 : 502, { kind: "error", text: started.error });
1929
1938
  }
1930
1939
  log("oidc_start", { kind: "mcp", issuer: O.issuerHost(ctx.gates.approvalOidc.issuer) });
1931
1940
  return redirect302(started.location);
@@ -1963,7 +1972,10 @@ async function issueMcpCode(ctx: TenantContext, clientName: string, redirect: st
1963
1972
  return { ok: true, response: redirectWith(ctx, redirect, { code, state: params.state }) };
1964
1973
  }
1965
1974
 
1966
- /** GET /oauth/authorize/oidc/callback. Same state binding as the pairing callback; then the existing code redirect. */
1975
+ /** GET /oauth/authorize/oidc/callback. Same state binding as the pairing callback; then the existing code redirect.
1976
+ * The identity provider navigates here with a GET. The code redirect is that navigation's 302, not a form post, so form-action does not apply to the client origin.
1977
+ * The error page has no form.
1978
+ */
1967
1979
  async function mcpOidcCallback(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
1968
1980
  const nonce = P.randomB64();
1969
1981
  const fail = (status: number, text: string) => new Response(P.pageShell("Connect an MCP client", nonce, `<p class="n error">${P.esc(text)}</p>`), { status, headers: P.pageHeaders(nonce) });
@@ -1,6 +1,7 @@
1
1
  // Device-flow pairing (OAuth 2.0 Device Authorization Grant, RFC 8628): pure helpers (Web Crypto only), so they run
2
2
  // in Workers and in Node's test runner. The HTTP handlers live in index.ts.
3
3
  import { isPrivateHost } from "./a2a.ts";
4
+ import { pageStyle } from "./theme.ts";
4
5
 
5
6
  /** How every hint tells an agent to run the CLI: npx, latest release, no global install (agent sandboxes block global
6
7
  * installs as untrusted code). WAKE_CLI_COMMAND overrides it for the owner's own wake hints only. */
@@ -223,17 +224,11 @@ export function devicePage(m: PageModel, nonce: string): string {
223
224
  return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
224
225
  }
225
226
 
226
- const STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
227
- h1{font-size:1.3rem}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #ddd;vertical-align:top;word-break:break-all}th{width:40%;font-weight:600;word-break:normal}
228
- label{display:block;font-weight:600;margin:.8rem 0 .3rem}input{font:inherit;width:100%;box-sizing:border-box;padding:.5rem;border:1px solid #888;border-radius:4px}
229
- .b{display:flex;gap:.6rem;margin-top:1rem}button{font:inherit;padding:.5rem 1.2rem;border:0;border-radius:4px;background:#1a5fb4;color:#fff;cursor:pointer}button.d{background:#a51d2d}
230
- h2{font-size:1.05rem;margin-top:1.5rem}ul{padding-left:1.2rem}a{color:#1a5fb4}.n{padding:.6rem .8rem;border-radius:4px}.ok{background:#e6f4ea}.error{background:#fce8e6}.info{background:#e8f0fe}.w{font-size:.9rem;color:#444}code{background:#eee;padding:0 .2rem}`;
231
-
232
227
  /** The shared page frame: no scripts, no external assets, one nonce'd style block. `title` is escaped here. */
233
228
  export function pageShell(title: string, nonce: string, body: string): string {
234
229
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer">
235
230
  <title>${esc(title)}</title><style nonce="${nonce}">
236
- ${STYLE}
231
+ ${pageStyle()}
237
232
  </style></head><body><h1>${esc(title)}</h1>${body}</body></html>`;
238
233
  }
239
234
 
@@ -308,11 +303,31 @@ export function setupPage(m: SetupModel, nonce: string): string {
308
303
  </form>`);
309
304
  }
310
305
 
311
- /** Security headers for the /device page: strict CSP (no scripts; only this page's own style), no framing, no caching. */
312
- export function pageHeaders(nonce: string): Record<string, string> {
306
+ /** An http(s) origin (scheme, host, and port) with no userinfo. Anything else, including a path, is dropped. */
307
+ export function cspFormOrigin(raw: string): string {
308
+ try {
309
+ const u = new URL(raw);
310
+ if ((u.protocol !== "https:" && u.protocol !== "http:") || u.username || u.password) return "";
311
+ if (u.origin !== raw) return "";
312
+ return u.origin;
313
+ } catch { return ""; }
314
+ }
315
+
316
+ /**
317
+ * Security headers for the /device page: strict CSP (no scripts; only this page's own style), no framing, no caching.
318
+ * `formOrigins` are extra `form-action` origins. Chrome applies `form-action` to a form POST's redirect target,
319
+ * so a page whose POST answers 302 to another origin must list that origin here. With none, the directive stays `form-action 'self'`.
320
+ */
321
+ export function pageHeaders(nonce: string, formOrigins: readonly string[] = []): Record<string, string> {
322
+ const extra: string[] = [];
323
+ for (const raw of formOrigins) {
324
+ const origin = cspFormOrigin(raw);
325
+ if (origin && !extra.includes(origin)) extra.push(origin);
326
+ }
327
+ const form = ["'self'", ...extra].join(" ");
313
328
  return {
314
329
  "content-type": "text/html; charset=utf-8",
315
- "content-security-policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
330
+ "content-security-policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action ${form}; frame-ancestors 'none'; base-uri 'none'`,
316
331
  "x-frame-options": "DENY", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer",
317
332
  "cache-control": "no-store", "cross-origin-opener-policy": "same-origin",
318
333
  };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Neutral CSS for the inbox HTML pages (`/`, `/device`, `/device/setup`, MCP consent).
3
+ * It is inlined in one nonce'd `<style>` block. There is no external stylesheet.
4
+ * `WORKER_THEME` (a CSS file path) replaces this module at `cf build`. See `theme-build.mjs`.
5
+ */
6
+ export const PAGE_STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
7
+ h1{font-size:1.3rem}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #ddd;vertical-align:top;word-break:break-all}th{width:40%;font-weight:600;word-break:normal}
8
+ label{display:block;font-weight:600;margin:.8rem 0 .3rem}input{font:inherit;width:100%;box-sizing:border-box;padding:.5rem;border:1px solid #888;border-radius:4px}
9
+ .b{display:flex;gap:.6rem;margin-top:1rem}button{font:inherit;padding:.5rem 1.2rem;border:0;border-radius:4px;background:#1a5fb4;color:#fff;cursor:pointer}button.d{background:#a51d2d}
10
+ h2{font-size:1.05rem;margin-top:1.5rem}ul{padding-left:1.2rem}a{color:#1a5fb4}.n{padding:.6rem .8rem;border-radius:4px}.ok{background:#e6f4ea}.error{background:#fce8e6}.info{background:#e8f0fe}.w{font-size:.9rem;color:#444}code{background:#eee;padding:0 .2rem}`;
11
+
12
+ export function pageStyle(): string {
13
+ return PAGE_STYLE;
14
+ }
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
5
5
  import crypto from "node:crypto";
6
6
  import worker, { dispatch } from "../src/index.ts";
7
7
  import * as M from "../src/mcp.ts";
8
- import { approvalPolicy, hostOnZone, reachesControl, sameZoneHost } from "../src/approval-oidc.ts";
8
+ import { approvalPolicy, formActionOrigin, hostOnZone, reachesControl, sameZoneHost } from "../src/approval-oidc.ts";
9
9
  import { hostedTenantContext, parseGates, type WorkerBindings } from "../src/tenancy.ts";
10
10
  import { d1 } from "./d1.ts";
11
11
 
@@ -156,6 +156,70 @@ test("configured /device redirects to the issuer with PKCE and approves on a val
156
156
  assert.equal(list.data.oidcIssuer, "idp.example");
157
157
  });
158
158
 
159
+ function formAction(headers: Headers): string {
160
+ return /form-action ([^;]+)/.exec(headers.get("content-security-policy") || "")?.[1] || "";
161
+ }
162
+
163
+ test("form-action lists only the OpenID Connect issuer origin, and only when that approval is on", async (t) => {
164
+ assert.equal(formActionOrigin(ISSUER), ISSUER);
165
+ assert.equal(formActionOrigin(`${ISSUER}/realms/app`), ISSUER);
166
+ assert.equal(formActionOrigin("https://idp.example:8443/realms/app"), "https://idp.example:8443");
167
+ assert.equal(formActionOrigin("https://user:secret@idp.example"), "");
168
+ assert.equal(formActionOrigin("http://idp.example"), "");
169
+
170
+ const s = setup(oidcEnv); t.after(s.restore);
171
+ const d = await s.start();
172
+ const g = await s.page(d.data.user_code);
173
+ assert.equal(formAction(g.headers), `'self' ${ISSUER}`);
174
+ assert.equal(g.headers.get("content-security-policy")!.includes(`${ISSUER}/`), false);
175
+
176
+ const downFetch = globalThis.fetch;
177
+ globalThis.fetch = (async () => { throw new Error("down"); }) as typeof fetch;
178
+ const failed = await s.postOidc(d.data.user_code);
179
+ globalThis.fetch = downFetch;
180
+ assert.equal(failed.post.status, 502, failed.post.text);
181
+ assert.match(failed.post.text, /Approve with idp\.example/);
182
+ assert.equal(formAction(failed.post.headers), `'self' ${ISSUER}`);
183
+
184
+ const back = await s.call("GET", "/device/oidc/callback?error=access_denied");
185
+ assert.equal(back.status, 400);
186
+ assert.equal(formAction(back.headers), `'self' ${ISSUER}`);
187
+
188
+ const setupPage = await s.call("GET", "/device/setup");
189
+ assert.equal(formAction(setupPage.headers), "'self'");
190
+ const land = await s.call("GET", "/", { headers: { accept: "text/html" } });
191
+ assert.match(land.headers.get("content-security-policy") || "", /form-action 'none'/);
192
+
193
+ const pw = setup({ ...oidcEnv, APPROVAL_METHODS: "password" }); t.after(pw.restore);
194
+ const d2 = await pw.start();
195
+ const g2 = await pw.page(d2.data.user_code);
196
+ assert.equal(g2.text.includes("Approve with"), false);
197
+ assert.equal(formAction(g2.headers), "'self'");
198
+ });
199
+
200
+ test("an authorization endpoint on another origin is not started", async (t) => {
201
+ const s = setup(oidcEnv); t.after(s.restore);
202
+ const inner = globalThis.fetch;
203
+ globalThis.fetch = (async (url: any, init: any) => {
204
+ const u = new URL(String(url));
205
+ if (u.origin === ISSUER && u.pathname === "/.well-known/openid-configuration") {
206
+ return Response.json({
207
+ issuer: ISSUER, authorization_endpoint: "https://login.example/authorize",
208
+ token_endpoint: `${ISSUER}/token`, jwks_uri: `${ISSUER}/jwks`,
209
+ });
210
+ }
211
+ return inner(url, init);
212
+ }) as typeof fetch;
213
+ const d = await s.start();
214
+ const { post } = await s.postOidc(d.data.user_code as string);
215
+ assert.equal(post.status, 400, post.text);
216
+ assert.match(post.text, /not on the issuer origin/);
217
+ assert.equal(post.headers.get("location"), null);
218
+ const tok = await s.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
219
+ assert.equal(tok.data.error, "authorization_pending");
220
+ assert.equal(s.logs.join("\n").includes("login.example"), false);
221
+ });
222
+
159
223
  test("wrong sub, wrong nonce and expired exp do not approve", async (t) => {
160
224
  const s = setup(oidcEnv); t.after(s.restore);
161
225
  const bad = async (over: Record<string, unknown>) => {
@@ -314,6 +378,7 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
314
378
  const g = await s.call("GET", `/oauth/authorize?${new URLSearchParams(q)}`);
315
379
  assert.match(g.text, /Continue with idp\.example/);
316
380
  assert.match(g.text, /No approval password is set yet/);
381
+ assert.equal(/form-action ([^;]+)/.exec(g.headers.get("content-security-policy") || "")?.[1], `'self' https://claude.ai ${ISSUER}`);
317
382
  const cookie = (g.headers.get("set-cookie") || "").split(";")[0];
318
383
  const csrf = /name="csrf" value="([^"]+)"/.exec(g.text)![1];
319
384
  const post = await s.call("POST", "/oauth/authorize", { form: { ...q, csrf, decision: "oidc" }, cookie, headers: { origin: BASE } });
@@ -323,6 +388,10 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
323
388
  s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
324
389
  const back = await s.call("GET", `/oauth/authorize/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
325
390
  assert.equal(back.status, 302, back.text);
391
+ const callbackError = await s.call("GET", "/oauth/authorize/oidc/callback?error=access_denied");
392
+ assert.equal(callbackError.status, 400);
393
+ assert.equal(callbackError.text.includes("<form"), false);
394
+ assert.equal(/form-action ([^;]+)/.exec(callbackError.headers.get("content-security-policy") || "")?.[1], "'self'");
326
395
  const client = new URL(back.headers.get("location")!);
327
396
  assert.equal(client.origin, "https://claude.ai");
328
397
  assert.equal(client.searchParams.get("state"), "st-1");
@@ -288,6 +288,25 @@ test("human approval: pending, slow_down, page approve with the password, single
288
288
  assert.equal((await s.call("POST", "/", { json: { jsonrpc: "2.0", id: 1, method: "GetTask", params: { id: "x" } }, headers: { authorization: `Bearer ${p.data.access_token}` } })).status, 401);
289
289
  });
290
290
 
291
+ test("a pairing label comes from the client name, a second one is suffixed, and inbox from uses it", async (t) => {
292
+ const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
293
+ const first = (await s.start({ client_name: "tester-alpha", client_id: "other-alias" }, "203.0.113.40")).data;
294
+ const approved = await s.owner("POST", `/owner/pairing/${first.user_code}/approve`);
295
+ assert.equal(approved.data.label, "tester-alpha");
296
+ const token = (await s.poll(first.device_code)).data.access_token;
297
+ const sent = await s.call("POST", "/", {
298
+ json: { jsonrpc: "2.0", id: 1, method: "SendMessage", params: { message: { role: "ROLE_USER", parts: [{ text: "hello" }] } } },
299
+ headers: { authorization: `Bearer ${token}`, "a2a-version": "1.0" },
300
+ });
301
+ assert.equal(sent.status, 200, JSON.stringify(sent.data));
302
+ const inbox = await s.owner("GET", "/owner/inbox");
303
+ assert.equal(inbox.data.length, 1);
304
+ assert.equal(inbox.data[0].from, "tester-alpha");
305
+ assert.equal(inbox.data[0].text, "hello");
306
+ const second = (await s.start({ client_name: "tester-alpha", client_id: "another" }, "203.0.113.41")).data;
307
+ assert.equal((await s.owner("POST", `/owner/pairing/${second.user_code}/approve`)).data.label, "tester-alpha-2");
308
+ });
309
+
291
310
  test("labels are deduplicated; denied and expired requests; JSON bodies; bad requests", async (t) => {
292
311
  const s = setup({ PAIRING_APPROVAL: "agent" }); t.after(s.restore);
293
312
  await s.owner("POST", "/owner/peers", { label: "barry" });
@@ -0,0 +1,85 @@
1
+ // Neutral inbox CSS, and the build-time WORKER_THEME check. Run: npm test
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { PAGE_STYLE, pageStyle } from "../src/theme.ts";
8
+ import { pageShell } from "../src/pairing.ts";
9
+ import { applyWorkerTheme, assertInlineCss, cssForChecks } from "../theme-build.mjs";
10
+
11
+ test("the neutral theme is inlined once and loads nothing", () => {
12
+ assert.equal(pageStyle(), PAGE_STYLE);
13
+ assert.equal(PAGE_STYLE.includes("background:#fafafa"), true);
14
+ assert.equal(PAGE_STYLE.includes("background:#1a5fb4"), true);
15
+ assert.doesNotThrow(() => assertInlineCss(PAGE_STYLE));
16
+ const html = pageShell("Inbox", "nonce-1", "<p>Hello</p>");
17
+ assert.equal(html.includes(`<style nonce="nonce-1">\n${PAGE_STYLE}\n</style>`), true);
18
+ assert.equal((html.match(/<style/g) || []).length, 1);
19
+ assert.equal(/<link|<script|<img|url\s*\(|@import/i.test(html), false);
20
+ });
21
+
22
+ test("WORKER_THEME replaces the module only when the CSS stays inline", () => {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-theme-"));
24
+ const file = path.join(dir, "theme.css");
25
+ const css = "@media (max-width: 40rem) { body { background: #fff; color: \\23 222; } }";
26
+ fs.writeFileSync(file, css);
27
+ const next = applyWorkerTheme("/work/src/theme.ts", file);
28
+ assert.match(next || "", /export const PAGE_STYLE = /);
29
+ assert.match(next || "", /background: #fff/);
30
+ assert.match(next || "", /\\\\23 222/);
31
+ assert.equal(applyWorkerTheme("/work/src/theme.ts", undefined), null);
32
+ assert.equal(applyWorkerTheme("/work/src/index.ts", file), null);
33
+
34
+ for (const bad of [
35
+ "",
36
+ "body { background: url(https://example.com/a.png); }",
37
+ "@import 'other.css';",
38
+ "@font-face { font-family: x; }",
39
+ "body { background: red; } </style><script>",
40
+ "body { background: expression(alert(1)); }",
41
+ "a { color: javascript:alert(1); }",
42
+ "body{background:u\\72l(https://example.com/a.png)}",
43
+ "body{background:\\75\\72\\6c(https://example.com/a.png)}",
44
+ "body{background:u\\000072l(https://example.com/a.png)}",
45
+ "@\\69mport \"https://example.com/x.css\"",
46
+ "@\\69 mport \"https://example.com/x.css\"",
47
+ "@\\69\\6d\\70\\6f\\72\\74 \"https://example.com/x.css\"",
48
+ "body{background:url/*x*/(https://example.com/a.png)}",
49
+ "@font\\2d face { font-family: x; }",
50
+ "a{\"/*\";background:url(https://example.com/a.png);content:\"*/\"}",
51
+ "a{content:\"foo\\\"bar\";background:url(https://example.com/a.png)}",
52
+ "a{content:\"foo\\\nbar\";background:url(https://example.com/a.png)}",
53
+ "a{content:\"url(https://example.com/a.png)\nbackground:url(https://example.com/b.png)}",
54
+ "body{background-image:image-set(\"https://example.com/p.png\" 1x)}",
55
+ "body{background-image:image(\"https://example.com/p.png\")}",
56
+ "body{background-image:-webkit-image-set(\"https://example.com/p.png\" 1x)}",
57
+ "body{background-image:image-\\73 et(\"https://example.com/p.png\" 1x)}",
58
+ "body{background-image:\\69mage(\"https://example.com/p.png\")}",
59
+ "body{background-image:image-set/*x*/(\"https://example.com/p.png\" 1x)}",
60
+ ]) {
61
+ fs.writeFileSync(file, bad);
62
+ assert.throws(() => applyWorkerTheme("/work/src/theme.ts", file), /WORKER_THEME was refused/);
63
+ }
64
+ for (const ok of [
65
+ "/* url(https://example.com/a.png) */ body { color: red }",
66
+ "a { content: \"url(https://example.com/a.png)\" }",
67
+ "a { content: \"foo\\\nurl(https://example.com/a.png)\" }",
68
+ "body { color: \\3c }",
69
+ "body { background-image: linear-gradient(#fff, #000); }",
70
+ "a { content: \"image-set(https://example.com/p.png)\" }",
71
+ ]) {
72
+ fs.writeFileSync(file, ok);
73
+ assert.doesNotThrow(() => applyWorkerTheme("/work/src/theme.ts", file));
74
+ }
75
+ assert.throws(() => applyWorkerTheme("/work/src/theme.ts", path.join(dir, "missing.css")), /could not be read/);
76
+ });
77
+
78
+ test("the resource check decodes escapes and keeps strings and comments closed", () => {
79
+ assert.match(cssForChecks("u\\72l(https://example.com/a.png)"), /url\(https:\/\/example.com\/a.png\)/);
80
+ assert.match(cssForChecks("@\\69 mport \"x.css\""), /@import ""/);
81
+ assert.equal(/url\s*\(/i.test(cssForChecks("/* url(https://example.com/a.png) */ body{color:red}")), false);
82
+ assert.equal(/url\s*\(/i.test(cssForChecks("a{content:\"url(https://example.com/a.png)\"}")), false);
83
+ assert.match(cssForChecks("a{\"/*\";background:url(https://example.com/a.png)}"), /url\(https:\/\/example.com\/a.png\)/);
84
+ assert.match(cssForChecks("color:\\23 222"), /color:#222/);
85
+ });
@@ -0,0 +1,162 @@
1
+ // Build-time check for WORKER_THEME. The Worker inlines the result in a nonce'd style block and
2
+ // does not load a stylesheet, font, or image. Unsafe CSS is refused so the build stops.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const MAX = 16384;
7
+
8
+ function isHex(char) {
9
+ return char !== undefined && ((char >= "0" && char <= "9") || (char >= "a" && char <= "f") || (char >= "A" && char <= "F"));
10
+ }
11
+
12
+ function isCssSpace(char) {
13
+ return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f";
14
+ }
15
+
16
+ function isNewline(char) {
17
+ return char === "\n" || char === "\r" || char === "\f";
18
+ }
19
+
20
+ function consumeNewline(css, i) {
21
+ if (css[i] === "\r" && css[i + 1] === "\n") return i + 2;
22
+ return i + 1;
23
+ }
24
+
25
+ /**
26
+ * One CSS escape at a backslash. A backslash before a newline is not an escape (`newline`);
27
+ * the caller decides whether that pair continues a string or stays in the sheet.
28
+ * @param {string} css
29
+ * @param {number} i index of the backslash
30
+ */
31
+ function consumeEscape(css, i) {
32
+ i++;
33
+ if (i >= css.length) return { type: "char", char: "\uFFFD", next: i };
34
+ if (isNewline(css[i])) return { type: "newline", next: i };
35
+ if (isHex(css[i])) {
36
+ let hex = "";
37
+ while (hex.length < 6 && i < css.length && isHex(css[i])) hex += css[i++];
38
+ if (i < css.length && isCssSpace(css[i])) i = isNewline(css[i]) ? consumeNewline(css, i) : i + 1;
39
+ const codePoint = Number.parseInt(hex, 16);
40
+ const surrogate = codePoint >= 0xd800 && codePoint <= 0xdfff;
41
+ const char = codePoint === 0 || surrogate || codePoint > 0x10ffff ? "\uFFFD" : String.fromCodePoint(codePoint);
42
+ return { type: "char", char, next: i };
43
+ }
44
+ return { type: "char", char: css[i], next: i + 1 };
45
+ }
46
+
47
+ /**
48
+ * Stylesheet text with escapes decoded, comments removed, and string contents removed.
49
+ * Markers are recognized on the raw text, so a string cannot pretend to be a comment,
50
+ * and a decoded character is not read again as a quote or a comment.
51
+ * @param {string} css
52
+ */
53
+ export function cssForChecks(css) {
54
+ let out = "";
55
+ let i = 0;
56
+ /** @type {"normal" | "comment" | "string"} */
57
+ let mode = "normal";
58
+ let quote = "";
59
+ while (i < css.length) {
60
+ if (mode === "comment") {
61
+ if (css[i] === "*" && css[i + 1] === "/") {
62
+ mode = "normal";
63
+ i += 2;
64
+ } else i++;
65
+ continue;
66
+ }
67
+ if (mode === "string") {
68
+ if (css[i] === "\\") {
69
+ const esc = consumeEscape(css, i);
70
+ i = esc.type === "newline" ? consumeNewline(css, esc.next) : esc.next;
71
+ continue;
72
+ }
73
+ if (isNewline(css[i])) {
74
+ mode = "normal";
75
+ out += "\n";
76
+ i = consumeNewline(css, i);
77
+ continue;
78
+ }
79
+ if (css[i] === quote) {
80
+ mode = "normal";
81
+ out += quote;
82
+ i++;
83
+ continue;
84
+ }
85
+ i++;
86
+ continue;
87
+ }
88
+ if (css[i] === "/" && css[i + 1] === "*") {
89
+ out += " ";
90
+ mode = "comment";
91
+ i += 2;
92
+ continue;
93
+ }
94
+ if (css[i] === '"' || css[i] === "'") {
95
+ quote = css[i];
96
+ out += quote;
97
+ mode = "string";
98
+ i++;
99
+ continue;
100
+ }
101
+ if (css[i] === "\\") {
102
+ const esc = consumeEscape(css, i);
103
+ if (esc.type === "newline") {
104
+ out += "\\";
105
+ i = esc.next;
106
+ continue;
107
+ }
108
+ out += esc.char;
109
+ i = esc.next;
110
+ continue;
111
+ }
112
+ out += css[i++];
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** @param {string} css */
118
+ export function assertInlineCss(css) {
119
+ if (typeof css !== "string") throw new Error("the theme is not text");
120
+ const text = css.replace(/^\uFEFF/, "");
121
+ if (!text.trim()) throw new Error("the theme is empty");
122
+ if (text.length > MAX) throw new Error("the theme is too long");
123
+ if (/[<>]/.test(text)) throw new Error("the theme has a tag");
124
+ const parsed = cssForChecks(text);
125
+ if (/@import\b/i.test(parsed)) throw new Error("the theme imports a stylesheet");
126
+ if (/@font-face\b/i.test(parsed)) throw new Error("the theme loads a font");
127
+ if (/url\s*\(/i.test(parsed)) throw new Error("the theme loads a url");
128
+ // image() and image-set() take a string as an image source, so the address never appears as url().
129
+ if (/\bimage-set\s*\(/i.test(parsed) || /\bimage\s*\(/i.test(parsed)) throw new Error("the theme loads an image");
130
+ if (/expression\s*\(/i.test(parsed) || /javascript\s*:/i.test(parsed)) throw new Error("the theme has a script");
131
+ if (/-moz-binding\s*:/i.test(parsed) || /behavior\s*:/i.test(parsed)) throw new Error("the theme has a binding");
132
+ return text;
133
+ }
134
+
135
+ /** Module source with the same exports as `src/theme.ts`. */
136
+ export function themeModule(css) {
137
+ const safe = assertInlineCss(css);
138
+ return `export const PAGE_STYLE = ${JSON.stringify(safe)};\nexport function pageStyle(): string {\n\treturn PAGE_STYLE;\n}\n`;
139
+ }
140
+
141
+ /**
142
+ * Replacement for `src/theme.ts` when `file` is set. Null leaves the neutral module in place.
143
+ * @param {string} id
144
+ * @param {string | undefined} file
145
+ */
146
+ export function applyWorkerTheme(id, file) {
147
+ if (!file) return null;
148
+ const clean = id.split("?")[0].replaceAll("\\", "/");
149
+ if (!clean.endsWith("/src/theme.ts")) return null;
150
+ let css;
151
+ try {
152
+ css = fs.readFileSync(path.resolve(file), "utf8");
153
+ } catch {
154
+ throw new Error("WORKER_THEME could not be read");
155
+ }
156
+ try {
157
+ return themeModule(css);
158
+ } catch (error) {
159
+ const reason = error instanceof Error ? error.message : "the theme was refused";
160
+ throw new Error(`WORKER_THEME was refused (${reason})`);
161
+ }
162
+ }
@@ -1,6 +1,17 @@
1
1
  import { cloudflare } from "@cloudflare/vite-plugin";
2
2
  import { defineConfig } from "vite";
3
+ import { applyWorkerTheme } from "./theme-build.mjs";
3
4
 
4
5
  export default defineConfig({
5
- plugins: [cloudflare()],
6
+ plugins: [
7
+ {
8
+ name: "inbox-theme",
9
+ enforce: "pre",
10
+ transform(_code, id) {
11
+ const next = applyWorkerTheme(id, process.env.WORKER_THEME);
12
+ return next ? { code: next, map: null } : null;
13
+ },
14
+ },
15
+ cloudflare(),
16
+ ],
6
17
  });