a2a-exposed 0.7.9 → 0.7.10

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
@@ -179,6 +179,8 @@ Control plane account (only when that deployment's login is on)
179
179
  page. Creating an agent shows the owner token once. That page is not stored.
180
180
  FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
181
181
  Unset, there is no limit. The database rejects another insert.
182
+ SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
183
+ A refusal returns to /app?error=invite and the page says Sign-in is invite-only for now.
182
184
  The control Worker logs social_sign_in_failed before the
183
185
  provider page, social_sign_in_timeout when that sign-in does not
184
186
  return, and oauth_callback_failed when the provider sends the browser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
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) });
@@ -308,11 +308,31 @@ export function setupPage(m: SetupModel, nonce: string): string {
308
308
  </form>`);
309
309
  }
310
310
 
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> {
311
+ /** An http(s) origin (scheme, host, and port) with no userinfo. Anything else, including a path, is dropped. */
312
+ export function cspFormOrigin(raw: string): string {
313
+ try {
314
+ const u = new URL(raw);
315
+ if ((u.protocol !== "https:" && u.protocol !== "http:") || u.username || u.password) return "";
316
+ if (u.origin !== raw) return "";
317
+ return u.origin;
318
+ } catch { return ""; }
319
+ }
320
+
321
+ /**
322
+ * Security headers for the /device page: strict CSP (no scripts; only this page's own style), no framing, no caching.
323
+ * `formOrigins` are extra `form-action` origins. Chrome applies `form-action` to a form POST's redirect target,
324
+ * so a page whose POST answers 302 to another origin must list that origin here. With none, the directive stays `form-action 'self'`.
325
+ */
326
+ export function pageHeaders(nonce: string, formOrigins: readonly string[] = []): Record<string, string> {
327
+ const extra: string[] = [];
328
+ for (const raw of formOrigins) {
329
+ const origin = cspFormOrigin(raw);
330
+ if (origin && !extra.includes(origin)) extra.push(origin);
331
+ }
332
+ const form = ["'self'", ...extra].join(" ");
313
333
  return {
314
334
  "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'`,
335
+ "content-security-policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action ${form}; frame-ancestors 'none'; base-uri 'none'`,
316
336
  "x-frame-options": "DENY", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer",
317
337
  "cache-control": "no-store", "cross-origin-opener-policy": "same-origin",
318
338
  };
@@ -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");