a2a-exposed 0.7.7 → 0.7.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2a-exposed",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
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": {
@@ -76,6 +76,11 @@ if (hosted) {
76
76
  envBindings.TENANT_DO = bindings.durableObject({ worker: name, exportName: "TenantStore" });
77
77
  envBindings.TENANT_DIRECTORY = bindings.kv();
78
78
  }
79
+ // Optional service binding to the control Worker. Unset: OpenID Connect keeps using fetch, which is
80
+ // what a self-host deploy needs for an external issuer. Set A2A_CONTROL_SERVICE to that Worker's name
81
+ // so a tenant on the same zone does not fetch the issuer through the public route.
82
+ const controlService = v("A2A_CONTROL_SERVICE");
83
+ if (controlService) envBindings.CONTROL_SERVICE = bindings.worker({ worker: controlService });
79
84
  if (backups) {
80
85
  const region = v("DATA_REGION");
81
86
  const bucketName = v("A2A_BACKUP_BUCKET_NAME");
@@ -58,6 +58,9 @@ WAKE_PRESET=generic
58
58
  # APPROVAL_METHODS=password
59
59
  # Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET, and for a hosted deploy TENANT_SECRETS_KEY.
60
60
  # Hosted approval uses the tenant's pushed approval object, not these Worker settings.
61
+ # When that issuer is on the same zone as the tenant host, set this to the control Worker's name.
62
+ # Discovery, token and JWKS then use the CONTROL_SERVICE binding. Unset: those calls use fetch.
63
+ # A2A_CONTROL_SERVICE=a2a-exposed-control
61
64
  # Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
62
65
  # A2A_BACKUP_BUCKET=1
63
66
  # A2A_BACKUP_BUCKET_NAME=a2a-exposed-backups
@@ -27,6 +27,35 @@ export function issuerBase(issuer: string): string {
27
27
  return issuer.replace(/\/+$/, "");
28
28
  }
29
29
 
30
+ function bareHost(host: string): string {
31
+ return host.trim().toLowerCase().replace(/^\.+|\.+$/g, "");
32
+ }
33
+
34
+ /** True when `host` is `zone` or a subdomain of it. `notexample.com` is not under `example.com`. */
35
+ export function hostOnZone(host: string, zone: string): boolean {
36
+ const name = bareHost(host);
37
+ const parent = bareHost(zone);
38
+ if (!name || !parent) return false;
39
+ return name === parent || name.endsWith(`.${parent}`);
40
+ }
41
+
42
+ /**
43
+ * True when the two hosts are the same, or one is a subdomain of the other.
44
+ * `notexample.com` is not a subdomain of `example.com`.
45
+ */
46
+ export function sameZoneHost(pageHost: string, targetHost: string): boolean {
47
+ return hostOnZone(pageHost, targetHost) || hostOnZone(targetHost, pageHost);
48
+ }
49
+
50
+ /**
51
+ * Use the control binding when the target is on the configured tenant domain
52
+ * (`control.example.com` and `northwind.example.com` share `example.com`), or when one host is under the other.
53
+ */
54
+ export function reachesControl(pageHost: string, targetHost: string, tenantDomain: string): boolean {
55
+ if (hostOnZone(targetHost, tenantDomain)) return true;
56
+ return sameZoneHost(pageHost, targetHost);
57
+ }
58
+
30
59
  /**
31
60
  * What the approval pages offer.
32
61
  * OIDC is on when it is fully configured and methods include oidc, or when methods were not specified.
@@ -83,8 +112,106 @@ async function readJson(res: Response, max: number): Promise<any> {
83
112
  return JSON.parse(text);
84
113
  }
85
114
 
86
- async function getJson(url: string): Promise<any> {
87
- const res = await fetch(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(FETCH_MS) });
115
+ type IdpOp = "discovery" | "token" | "jwks";
116
+
117
+ function pageHostname(ctx: TenantContext): string {
118
+ try { return new URL(ctx.PUBLIC_URL).hostname; } catch { return ""; }
119
+ }
120
+
121
+ function logUnreachable(host: string, op: IdpOp, status: number, error?: unknown) {
122
+ const name = error instanceof Error ? error.name : "";
123
+ const kw: Record<string, unknown> = { ts: A.nowIso(), event: "idp_unreachable", op, status, host };
124
+ if (!status) kw.error = /^[A-Za-z][A-Za-z0-9_]{0,40}$/.test(name) ? name : "fetch_failed";
125
+ console.log(JSON.stringify(kw));
126
+ }
127
+
128
+ function startDeadline(): { signal: AbortSignal; stop: () => void } {
129
+ const ac = new AbortController();
130
+ const timer = setTimeout(() => ac.abort(), FETCH_MS);
131
+ return { signal: ac.signal, stop: () => clearTimeout(timer) };
132
+ }
133
+
134
+ function aborted(signal: AbortSignal): Promise<never> {
135
+ if (signal.aborted) return Promise.reject(new DOMException("The operation was aborted", "AbortError"));
136
+ return new Promise((_, reject) => {
137
+ signal.addEventListener("abort", () => reject(new DOMException("The operation was aborted", "AbortError")), { once: true });
138
+ });
139
+ }
140
+
141
+ /** An error page is cancelled and not read. Only a successful body is buffered, and only up to `max`. */
142
+ function dropBody(res: Response): void {
143
+ try { void res.body?.cancel().catch(() => {}); } catch { /* already closed */ }
144
+ }
145
+
146
+ async function takeBody(res: Response, signal: AbortSignal, max: number): Promise<Response> {
147
+ if (!res.ok) {
148
+ dropBody(res);
149
+ return new Response(null, { status: res.status, statusText: res.statusText });
150
+ }
151
+ const len = Number(res.headers.get("content-length") || 0);
152
+ if (len > max) {
153
+ dropBody(res);
154
+ throw new Error("large");
155
+ }
156
+ const text = await Promise.race([res.text(), aborted(signal)]);
157
+ if (text.length > max) throw new Error("large");
158
+ const headers = new Headers(res.headers);
159
+ headers.delete("content-length");
160
+ return new Response(text, { status: res.status, statusText: res.statusText, headers });
161
+ }
162
+
163
+ /**
164
+ * Discovery, token and JWKS for an issuer on this tenant's zone go through CONTROL_SERVICE.
165
+ * A public fetch of that zone can miss the control Worker (wildcard route, 522, or the site Worker).
166
+ * Any other issuer, and a deploy with the binding unset, uses fetch.
167
+ * The binding request sets Host to the issuer host. Without that header the control Worker on the
168
+ * zone apex sees no Host, and the subrequest does not finish. Every call, binding or fetch, stops
169
+ * after FETCH_MS and the page says the provider could not be reached.
170
+ */
171
+ async function idpFetch(ctx: TenantContext, url: string, init: RequestInit, op: IdpOp): Promise<Response> {
172
+ let host = "";
173
+ try { host = new URL(url).hostname; } catch { /* logged as a failed fetch */ }
174
+ const binding = ctx.controlService();
175
+ const viaBinding = !!(binding && host && reachesControl(pageHostname(ctx), host, ctx.gates.tenantDomain));
176
+ const { signal, stop } = startDeadline();
177
+ try {
178
+ const res = await Promise.race([
179
+ viaBinding ? bindingFetch(binding!, url, init, signal) : publicFetch(url, init, signal),
180
+ aborted(signal),
181
+ ]);
182
+ const full = await Promise.race([takeBody(res, signal, 65536), aborted(signal)]);
183
+ if (!full.ok) logUnreachable(host, op, full.status);
184
+ return full;
185
+ } catch (error) {
186
+ logUnreachable(host, op, 0, error);
187
+ throw new Error("http");
188
+ } finally {
189
+ stop();
190
+ }
191
+ }
192
+
193
+ function publicFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
194
+ return fetch(url, { method: init.method, headers: init.headers, body: init.body, redirect: "error", signal });
195
+ }
196
+
197
+ async function bindingFetch(binding: NonNullable<ReturnType<TenantContext["controlService"]>>, url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
198
+ const headers = new Headers(init.headers);
199
+ // `new Request(url)` does not keep Host across a service binding. The apex issuer is then
200
+ // a request with no Host, which does not match the control Worker's route or ISSUER, and the
201
+ // subrequest never completes. Set Host to the URL host. `redirect` and `signal` stay off the
202
+ // Request (they do not cross the binding). `signal` is passed to fetch so the runtime cancels it.
203
+ headers.set("host", new URL(url).host);
204
+ const req = new Request(url, { method: init.method || "GET", headers, body: init.body });
205
+ try {
206
+ return await binding.fetch(req, { signal });
207
+ } catch (error) {
208
+ if (error instanceof TypeError && !signal.aborted) return binding.fetch(req);
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ async function getJson(ctx: TenantContext, url: string, op: IdpOp): Promise<any> {
214
+ const res = await idpFetch(ctx, url, { headers: { accept: "application/json" } }, op);
88
215
  if (!res.ok) throw new Error("http");
89
216
  return readJson(res, 65536);
90
217
  }
@@ -95,8 +222,8 @@ interface Discovery {
95
222
  jwks_uri: string;
96
223
  }
97
224
 
98
- async function discover(issuer: string): Promise<Discovery> {
99
- const doc = await getJson(`${issuerBase(issuer)}/.well-known/openid-configuration`);
225
+ async function discover(ctx: TenantContext, issuer: string): Promise<Discovery> {
226
+ const doc = await getJson(ctx, `${issuerBase(issuer)}/.well-known/openid-configuration`, "discovery");
100
227
  if (issuerBase(String(doc?.issuer || "")) !== issuerBase(issuer)) throw new Error("issuer");
101
228
  return {
102
229
  authorization_endpoint: httpsEndpoint(doc.authorization_endpoint),
@@ -112,7 +239,7 @@ export async function startOidc(ctx: TenantContext, o: {
112
239
  const gate = ctx.gates.approvalOidc;
113
240
  if (!approvalPolicy(gate).oidc || !ctx.approvalClientSecret()) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config" };
114
241
  let disc: Discovery;
115
- try { disc = await discover(gate.issuer); }
242
+ try { disc = await discover(ctx, gate.issuer); }
116
243
  catch { return { ok: false, error: UNREACHABLE, reason: "discovery" }; }
117
244
  const { verifier, challenge } = await pkce();
118
245
  const state = b64url(crypto.getRandomValues(new Uint8Array(32)));
@@ -217,7 +344,7 @@ export async function finishOidc(ctx: TenantContext, o: {
217
344
  const secret = ctx.approvalClientSecret();
218
345
  if (!approvalPolicy(gate).oidc || !secret) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
219
346
  let disc: Discovery;
220
- try { disc = await discover(gate.issuer); }
347
+ try { disc = await discover(ctx, gate.issuer); }
221
348
  catch { return { ok: false, error: UNREACHABLE, reason: "discovery", userCode }; }
222
349
  let idToken = "";
223
350
  try {
@@ -225,16 +352,16 @@ export async function finishOidc(ctx: TenantContext, o: {
225
352
  grant_type: "authorization_code", code: o.code, redirect_uri: o.redirectUri,
226
353
  client_id: gate.clientId, client_secret: secret, code_verifier: row.verifier,
227
354
  });
228
- const res = await fetch(disc.token_endpoint, {
355
+ const res = await idpFetch(ctx, disc.token_endpoint, {
229
356
  method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
230
- body, redirect: "error", signal: AbortSignal.timeout(FETCH_MS),
231
- });
357
+ body,
358
+ }, "token");
232
359
  if (!res.ok) return { ok: false, error: UNREACHABLE, reason: "token", userCode };
233
360
  const tok = await readJson(res, 65536);
234
361
  idToken = typeof tok.id_token === "string" ? tok.id_token : "";
235
362
  } catch { return { ok: false, error: UNREACHABLE, reason: "token", userCode }; }
236
363
  let jwks: { keys?: any[] };
237
- try { jwks = await getJson(disc.jwks_uri); }
364
+ try { jwks = await getJson(ctx, disc.jwks_uri, "jwks"); }
238
365
  catch { return { ok: false, error: UNREACHABLE, reason: "jwks", userCode }; }
239
366
  const verified = await verifyIdToken(idToken, jwks, { iss: gate.issuer, aud: gate.clientId, nonce: row.nonce, subjects: gate.allowedSubjects });
240
367
  if ("reason" in verified) return { ok: false, error: FAIL, reason: verified.reason, userCode };
@@ -194,7 +194,7 @@ export function devicePage(m: PageModel, nonce: string): string {
194
194
  const rows = r ? [
195
195
  ["Code", formatUserCode(r.userCode)],
196
196
  ["Agent (as it calls itself)", r.clientName || r.clientId || "(no name given)"],
197
- ...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Client id", r.clientId]] : []),
197
+ ...(r.clientId && r.clientName && r.clientId !== r.clientName ? [["Alias (claimed)", r.clientId]] : []),
198
198
  ["Agent card (claimed)", r.agentCardUrl ? r.agentCardUrl + (cardIsPrivate(r.agentCardUrl) ? " (private network address, e.g. a Tailnet: not publicly reachable, so this inbox can't check it; informational only)" : "") : "(none given)"],
199
199
  ["Requested from", [r.ip || "unknown address", r.country].filter(Boolean).join(", ")],
200
200
  ["Expires", `${Math.max(0, Math.round((r.expiresMs - Date.now()) / 60000))} min`],
@@ -202,7 +202,7 @@ export function devicePage(m: PageModel, nonce: string): string {
202
202
  ] : [];
203
203
  const notice = m.notice ? `<p class="n ${m.notice.kind}">${esc(m.notice.text)}</p>` : "";
204
204
  const details = r ? `<table>${rows.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join("")}</table>
205
- <p class="w">Approve only if you expected this request and the code matches the one you were given. The name and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(m.cli)} token revoke &lt;label&gt;</code>.</p>` : "";
205
+ <p class="w">Approve only if you expected this request and the code matches the one you were given. The name, alias and card URL are claimed by the requester. Approving gives it a token to send messages to this inbox; revoke it any time with <code>${esc(m.cli)} token revoke &lt;label&gt;</code>.</p>` : "";
206
206
  const noPassword = `<p class="n info">No approval password is set yet. Easiest: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and send you the one-time link, then set the password on that page. Or run <code>${esc(m.cli)} pair set-password</code> yourself in a terminal on the owner's machine. Then reload this page. You can deny without a password.</p>`;
207
207
  const denyOnly = `<form method="post" action="/device">
208
208
  <input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(r ? formatUserCode(r.userCode) : "")}">
@@ -52,6 +52,11 @@ export type ConfigFields = { [K in ConfigKey]?: string };
52
52
  */
53
53
  const HOSTED_PLATFORM_DEFAULTS: readonly ConfigKey[] = ["MAX_BODY", "RATE_PER_MIN", "PBKDF2_ITERATIONS", "MCP", "PAIRING_APPROVAL"];
54
54
 
55
+ /** Worker-to-Worker fetch. Present when the deploy sets `A2A_CONTROL_SERVICE`. */
56
+ export interface ControlFetcher {
57
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
58
+ }
59
+
55
60
  /** Bindings the Worker entry sees. Gate bindings are optional and empty by default. */
56
61
  export interface WorkerBindings extends ConfigFields {
57
62
  DB: SqlDb;
@@ -97,6 +102,11 @@ export interface WorkerBindings extends ConfigFields {
97
102
  APPROVAL_OIDC_ALLOWED_SUBJECTS?: string;
98
103
  /** Comma-separated: password, oidc. Unset keeps the password and also offers OIDC once it is fully configured. */
99
104
  APPROVAL_METHODS?: string;
105
+ /**
106
+ * Service binding to the control Worker. Used for OpenID Connect discovery, token and JWKS
107
+ * when the issuer is on this tenant's zone. Unset: those calls use `fetch` (self-host, and any other issuer).
108
+ */
109
+ CONTROL_SERVICE?: ControlFetcher;
100
110
  }
101
111
 
102
112
  export interface KvNamespace {
@@ -348,13 +358,15 @@ export class TenantContext implements ConfigFields {
348
358
  readonly #secretsKey?: string;
349
359
  /** OpenID Connect client secret. Never logged and never copied onto `gates`. */
350
360
  readonly #approvalClientSecret?: string;
361
+ /** Control Worker binding. Not a secret. Absent on a self-host deploy. */
362
+ readonly #controlService?: ControlFetcher;
351
363
  #peerKey: Promise<CryptoKey | null> | null = null;
352
364
 
353
365
  constructor(init: {
354
366
  id: string; mode: TenancyMode; status: string; db: SqlDb; publicUrl: string;
355
367
  gates: FeatureGates; limits: Record<string, unknown>; approval: Record<string, unknown>;
356
368
  config: ConfigFields; ownerToken?: string; ownerTokenHash?: string; secretsKey?: string;
357
- approvalClientSecret?: string;
369
+ approvalClientSecret?: string; controlService?: ControlFetcher;
358
370
  }) {
359
371
  this.id = init.id;
360
372
  this.mode = init.mode;
@@ -368,6 +380,7 @@ export class TenantContext implements ConfigFields {
368
380
  this.#ownerTokenHash = init.ownerTokenHash;
369
381
  this.#secretsKey = init.secretsKey;
370
382
  this.#approvalClientSecret = init.approvalClientSecret;
383
+ this.#controlService = init.controlService;
371
384
  Object.assign(this, init.config);
372
385
  }
373
386
 
@@ -376,6 +389,11 @@ export class TenantContext implements ConfigFields {
376
389
  return this.#approvalClientSecret || "";
377
390
  }
378
391
 
392
+ /** Control Worker binding, or undefined when this deploy has none. */
393
+ controlService(): ControlFetcher | undefined {
394
+ return this.#controlService;
395
+ }
396
+
379
397
  /**
380
398
  * Owner bearer check.
381
399
  * Self-host: hash both the presented token and OWNER_TOKEN, then compare (unchanged).
@@ -422,6 +440,7 @@ export function resolveTenant(env: WorkerBindings, req?: Request): TenantContext
422
440
  limits: {}, approval: {}, config: pickConfig(env, CONFIG_KEYS),
423
441
  ownerToken: env.OWNER_TOKEN, secretsKey: undefined,
424
442
  approvalClientSecret: (env.APPROVAL_OIDC_CLIENT_SECRET || "").trim(),
443
+ controlService: env.CONTROL_SERVICE,
425
444
  });
426
445
  }
427
446
 
@@ -446,5 +465,6 @@ export function hostedTenantContext(env: WorkerBindings, overlay: HostedOverlay)
446
465
  limits: overlay.limits || {}, approval: approval.publicRecord,
447
466
  config, ownerTokenHash: hash || undefined, secretsKey: parsed.secretsKey,
448
467
  approvalClientSecret: approval.clientSecret,
468
+ controlService: env.CONTROL_SERVICE,
449
469
  });
450
470
  }
@@ -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 } from "../src/approval-oidc.ts";
8
+ import { approvalPolicy, 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
 
@@ -117,6 +117,9 @@ test("unset /device has no OpenID Connect button", async (t) => {
117
117
 
118
118
  test("configured /device redirects to the issuer with PKCE and approves on a valid id token", async (t) => {
119
119
  const s = setup(oidcEnv); t.after(s.restore);
120
+ // Different zone from the tenant domain: the control binding must not see this issuer.
121
+ s.env.TENANT_DOMAIN = "example.com";
122
+ s.env.CONTROL_SERVICE = { fetch() { throw new Error("external issuer must use fetch"); } };
120
123
  const d = await s.start();
121
124
  const code = d.data.user_code as string;
122
125
  const { page, post } = await s.postOidc(code);
@@ -329,3 +332,182 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
329
332
  assert.equal(tok.status, 200, tok.text);
330
333
  assert.ok(String(tok.data.access_token).startsWith("a2amcp_"));
331
334
  });
335
+
336
+ test("sameZoneHost matches a parent zone and not a lookalike label", () => {
337
+ assert.equal(sameZoneHost("northwind.example.com", "example.com"), true);
338
+ assert.equal(sameZoneHost("example.com", "northwind.example.com"), true);
339
+ assert.equal(sameZoneHost("example.com", "example.com"), true);
340
+ assert.equal(sameZoneHost("agent.example.com", "idp.example"), false);
341
+ assert.equal(sameZoneHost("northwind.example.com", "control.example.com"), false);
342
+ assert.equal(sameZoneHost("notexample.com", "example.com"), false);
343
+ assert.equal(sameZoneHost("example.com.evil.example", "example.com"), false);
344
+ assert.equal(sameZoneHost("", "example.com"), false);
345
+ assert.equal(hostOnZone("control.example.com", "example.com"), true);
346
+ assert.equal(hostOnZone("notexample.com", "example.com"), false);
347
+ assert.equal(reachesControl("northwind.example.com", "control.example.com", "example.com"), true);
348
+ assert.equal(reachesControl("northwind.example.com", "control.example.com", ""), false);
349
+ assert.equal(reachesControl("agent.example.com", "idp.example", "example.com"), false);
350
+ });
351
+
352
+ const ZONE = "https://example.com";
353
+
354
+ test("a same-zone issuer is reached through CONTROL_SERVICE, not a public fetch", async (t) => {
355
+ const s = setup({ ...oidcEnv, APPROVAL_OIDC_ISSUER: ZONE }); t.after(s.restore);
356
+ const urls: string[] = [];
357
+ let publicCalls = 0;
358
+ let next = () => claims({ iss: ZONE });
359
+ s.env.CONTROL_SERVICE = {
360
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
361
+ const req = input instanceof Request ? input : new Request(input, init);
362
+ urls.push(new URL(req.url).pathname);
363
+ const u = new URL(req.url);
364
+ if (u.origin === ZONE && u.pathname === "/.well-known/openid-configuration")
365
+ return Response.json({ issuer: ZONE, authorization_endpoint: `${ZONE}/authorize`, token_endpoint: `${ZONE}/token`, jwks_uri: `${ZONE}/jwks` });
366
+ if (u.origin === ZONE && u.pathname === "/jwks") return Response.json({ keys: [jwk] });
367
+ if (u.origin === ZONE && u.pathname === "/token") {
368
+ s.tokenBodies.push(Object.fromEntries(new URLSearchParams(await req.text())));
369
+ return Response.json({ id_token: signJwt(next()) });
370
+ }
371
+ return new Response("missing", { status: 404 });
372
+ },
373
+ };
374
+ globalThis.fetch = (async () => { publicCalls++; return new Response("unreachable", { status: 522 }); }) as typeof fetch;
375
+ const d = await s.start();
376
+ const code = d.data.user_code as string;
377
+ const { post } = await s.postOidc(code);
378
+ assert.equal(post.status, 302, post.text);
379
+ const loc = new URL(post.headers.get("location")!);
380
+ assert.equal(loc.origin + loc.pathname, `${ZONE}/authorize`);
381
+ assert.equal(publicCalls, 0, "the public fetch of the same zone was not used");
382
+ assert.deepEqual(urls, ["/.well-known/openid-configuration"]);
383
+ next = () => claims({ iss: ZONE, nonce: loc.searchParams.get("nonce")! });
384
+ const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
385
+ assert.equal(back.status, 200, back.text);
386
+ assert.match(back.text, /Approved/);
387
+ assert.deepEqual(urls, ["/.well-known/openid-configuration", "/.well-known/openid-configuration", "/token", "/jwks"]);
388
+ assert.equal(s.tokenBodies[0].client_secret, SECRET);
389
+ assert.equal(publicCalls, 0);
390
+ const joined = s.logs.join("\n");
391
+ assert.equal(joined.includes("idp_unreachable"), false);
392
+ assert.equal(joined.includes(SECRET), false);
393
+ assert.equal(joined.includes("auth-code"), false);
394
+ });
395
+
396
+ test("a sibling control host on the tenant domain uses CONTROL_SERVICE", async (t) => {
397
+ const issuer = "https://control.example.com";
398
+ const s = setup({
399
+ ...oidcEnv, APPROVAL_OIDC_ISSUER: issuer, PUBLIC_URL: "https://northwind.example.com", TENANT_DOMAIN: "example.com",
400
+ });
401
+ t.after(s.restore);
402
+ const urls: string[] = [];
403
+ s.env.CONTROL_SERVICE = {
404
+ async fetch(input: RequestInfo | URL, init?: RequestInit) {
405
+ const req = input instanceof Request ? input : new Request(input, init);
406
+ urls.push(new URL(req.url).pathname);
407
+ return Response.json({
408
+ issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, jwks_uri: `${issuer}/jwks`,
409
+ });
410
+ },
411
+ };
412
+ globalThis.fetch = (async () => new Response("unreachable", { status: 522 })) as typeof fetch;
413
+ const d = await s.start();
414
+ const { post } = await s.postOidc(d.data.user_code);
415
+ assert.equal(post.status, 302, post.text);
416
+ assert.equal(new URL(post.headers.get("location")!).origin, issuer);
417
+ assert.deepEqual(urls, ["/.well-known/openid-configuration"]);
418
+ });
419
+
420
+ test("a failed same-zone fetch logs idp_unreachable with status and host", async (t) => {
421
+ const s = setup({ ...oidcEnv, APPROVAL_OIDC_ISSUER: ZONE }); t.after(s.restore);
422
+ s.env.CONTROL_SERVICE = { async fetch() { return new Response("origin is unreachable", { status: 522 }); } };
423
+ globalThis.fetch = (async () => { throw new Error("public fetch must not run"); }) as typeof fetch;
424
+ const d = await s.start();
425
+ const code = d.data.user_code as string;
426
+ const { post } = await s.postOidc(code);
427
+ assert.equal(post.status, 502, post.text);
428
+ assert.match(post.text, /The identity provider could not be reached/);
429
+ const line = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).find((entry) => entry?.event === "idp_unreachable");
430
+ assert.ok(line, s.logs.join("\n"));
431
+ assert.equal(line.op, "discovery");
432
+ assert.equal(line.status, 522);
433
+ assert.equal(line.host, "example.com");
434
+ assert.equal(JSON.stringify(line).includes(SECRET), false);
435
+ assert.equal(JSON.stringify(line).includes(code), false);
436
+ assert.equal(JSON.stringify(line).includes("origin is unreachable"), false);
437
+
438
+ delete s.env.CONTROL_SERVICE;
439
+ globalThis.fetch = (async () => { const err = new TypeError("connect failed"); throw err; }) as typeof fetch;
440
+ const again = await s.postOidc((await s.start()).data.user_code);
441
+ assert.equal(again.post.status, 502);
442
+ const thrown = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).filter((entry) => entry?.event === "idp_unreachable");
443
+ const last = thrown.at(-1);
444
+ assert.equal(last.status, 0);
445
+ assert.equal(last.host, "example.com");
446
+ assert.equal(last.error, "TypeError");
447
+ assert.equal(JSON.stringify(last).includes("connect failed"), false);
448
+ assert.equal(JSON.stringify(last).includes(SECRET), false);
449
+ });
450
+
451
+ test("an identity-provider error page is cancelled and not buffered", async (t) => {
452
+ const s = setup({ ...oidcEnv, APPROVAL_OIDC_ISSUER: ZONE }); t.after(s.restore);
453
+ let pulls = 0;
454
+ let cancelled = false;
455
+ s.env.CONTROL_SERVICE = {
456
+ async fetch() {
457
+ return new Response(new ReadableStream({
458
+ pull(controller) {
459
+ pulls++;
460
+ controller.enqueue(new Uint8Array(64 * 1024));
461
+ },
462
+ cancel() { cancelled = true; },
463
+ }), { status: 522 });
464
+ },
465
+ };
466
+ globalThis.fetch = (async () => { throw new Error("public fetch must not run"); }) as typeof fetch;
467
+ const d = await s.start();
468
+ const started = Date.now();
469
+ const { post } = await s.postOidc(d.data.user_code);
470
+ assert.ok(Date.now() - started < 1000, "error headers should return without reading the body");
471
+ assert.equal(post.status, 502);
472
+ assert.match(post.text, /The identity provider could not be reached/);
473
+ assert.equal(cancelled, true);
474
+ assert.ok(pulls < 3, `buffered ${pulls} chunks`);
475
+ const line = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).find((entry) => entry?.event === "idp_unreachable");
476
+ assert.equal(line?.status, 522);
477
+ assert.equal(line?.op, "discovery");
478
+ });
479
+
480
+ test("a same-zone issuer that does not answer is given up, and the binding request carries the issuer host", async (t) => {
481
+ const issuer = "https://example.com";
482
+ const s = setup({
483
+ ...oidcEnv, APPROVAL_OIDC_ISSUER: issuer, PUBLIC_URL: "https://northwind.example.com", TENANT_DOMAIN: "example.com",
484
+ });
485
+ t.after(s.restore);
486
+ let host = "";
487
+ s.env.CONTROL_SERVICE = {
488
+ async fetch(input: RequestInfo | URL) {
489
+ const req = input instanceof Request ? input : new Request(input);
490
+ host = req.headers.get("host") || "";
491
+ assert.equal(new URL(req.url).origin, issuer);
492
+ await new Promise((resolve) => setTimeout(resolve, 8000));
493
+ return Response.json({
494
+ issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, jwks_uri: `${issuer}/jwks`,
495
+ });
496
+ },
497
+ };
498
+ globalThis.fetch = (async () => { throw new Error("public fetch must not run"); }) as typeof fetch;
499
+ const d = await s.start();
500
+ const started = Date.now();
501
+ const { post } = await s.postOidc(d.data.user_code);
502
+ const waited = Date.now() - started;
503
+ assert.equal(host, "example.com");
504
+ assert.ok(waited < 7000, `waited ${waited}ms`);
505
+ assert.equal(post.status, 502, post.text);
506
+ assert.match(post.text, /The identity provider could not be reached/);
507
+ const line = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).find((entry) => entry?.event === "idp_unreachable");
508
+ assert.equal(line?.op, "discovery");
509
+ assert.equal(line?.status, 0);
510
+ assert.equal(line?.host, "example.com");
511
+ assert.equal(line?.error, "AbortError");
512
+ assert.equal(JSON.stringify(line).includes("The operation was aborted"), false);
513
+ });
@@ -219,3 +219,265 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
219
219
  await alice.stub.pushConfig({ version: 4, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hashA, config: { AGENT_NAME: "Alice 3" } });
220
220
  assert.equal((await mf.dispatchFetch("https://alice.example.com/health")).status, 403, "the object enforces a newer status than the directory cache");
221
221
  });
222
+
223
+ test("hosted approval reaches a same-zone issuer through CONTROL_SERVICE", async (t) => {
224
+ const issuer = "https://control.example.com";
225
+ const tenant = "https://northwind.example.com";
226
+ const bindingUrls: string[] = [];
227
+ const publicHosts: string[] = [];
228
+ const mf = await start({
229
+ bindings: {
230
+ TENANCY: "host",
231
+ TENANT_DOMAIN: "example.com",
232
+ TENANT_SECRETS_KEY: "platform-secret",
233
+ },
234
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-oidc" },
235
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
236
+ serviceBindings: {
237
+ CONTROL_SERVICE: async (request: Request) => {
238
+ const url = new URL(request.url);
239
+ bindingUrls.push(`${url.origin}${url.pathname}`);
240
+ if (url.origin === issuer && url.pathname === "/.well-known/openid-configuration") {
241
+ return Response.json({
242
+ issuer,
243
+ authorization_endpoint: `${issuer}/api/oidc/authorize`,
244
+ token_endpoint: `${issuer}/api/oidc/token`,
245
+ jwks_uri: `${issuer}/api/auth/jwks`,
246
+ });
247
+ }
248
+ return new Response("missing", { status: 404 });
249
+ },
250
+ },
251
+ // A public fetch of another host on this zone does not reach the control Worker. If it is attempted, answer 522.
252
+ outboundService: async (request: Request) => {
253
+ publicHosts.push(new URL(request.url).hostname);
254
+ return new Response("origin is unreachable", { status: 522 });
255
+ },
256
+ });
257
+ t.after(() => mf.dispose());
258
+ await mf.ready;
259
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
260
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO");
261
+ const hash = await sha256("owner-north");
262
+ const stub = ns.get(ns.idFromName("id-north")) as unknown as { pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }> };
263
+ const pushed = await stub.pushConfig({
264
+ version: 1, tenantId: "id-north", name: "northwind", status: "active", ownerTokenHash: hash,
265
+ approval: {
266
+ issuer, clientId: "client-1", clientSecret: "tenant-secret-value",
267
+ allowedSubjects: ["owner-1"], label: "Example",
268
+ },
269
+ });
270
+ assert.equal(pushed.applied, true, JSON.stringify(pushed));
271
+ await kv.put("tenant:northwind", JSON.stringify({ id: "id-north", status: "active", region: "default", version: 1 }));
272
+
273
+ const started = await mf.dispatchFetch(`${tenant}/oauth/device_authorization`, {
274
+ method: "POST",
275
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.8" },
276
+ body: "client_name=Barry+Bot&client_id=barry",
277
+ });
278
+ const device = await started.json() as { user_code?: string };
279
+ assert.equal(started.status, 200, JSON.stringify(device));
280
+ assert.ok(device.user_code);
281
+ const page = await mf.dispatchFetch(`${tenant}/device?user_code=${device.user_code}`, {
282
+ headers: { "cf-connecting-ip": "198.51.100.8" },
283
+ });
284
+ const html = await page.text();
285
+ assert.match(html, /Approve with Example/);
286
+ const csrf = /name="csrf" value="([^"]+)"/.exec(html)?.[1] ?? "";
287
+ const cookie = (page.headers.get("set-cookie") || "").split(";")[0];
288
+ const post = await mf.dispatchFetch(`${tenant}/device`, {
289
+ method: "POST",
290
+ redirect: "manual",
291
+ headers: {
292
+ "content-type": "application/x-www-form-urlencoded",
293
+ origin: tenant,
294
+ cookie,
295
+ "cf-connecting-ip": "198.51.100.8",
296
+ },
297
+ body: new URLSearchParams({ csrf, user_code: device.user_code, decision: "oidc" }).toString(),
298
+ });
299
+ assert.equal(post.status, 302, await post.text());
300
+ assert.equal(new URL(post.headers.get("location") || "").origin + new URL(post.headers.get("location") || "").pathname, `${issuer}/api/oidc/authorize`);
301
+ assert.deepEqual(bindingUrls, [`${issuer}/.well-known/openid-configuration`]);
302
+ assert.equal(publicHosts.includes("control.example.com"), false, `public fetch hit ${publicHosts.join(",")}`);
303
+
304
+ const missed = await start({
305
+ bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret" },
306
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-oidc-miss" },
307
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
308
+ outboundService: async () => new Response("origin is unreachable", { status: 522 }),
309
+ });
310
+ t.after(() => missed.dispose());
311
+ await missed.ready;
312
+ const missedKv = await missed.getKVNamespace("TENANT_DIRECTORY");
313
+ const missedNs = await missed.getDurableObjectNamespace("TENANT_DO");
314
+ const missedStub = missedNs.get(missedNs.idFromName("id-north")) as unknown as { pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }> };
315
+ await missedStub.pushConfig({
316
+ version: 1, tenantId: "id-north", name: "northwind", status: "active", ownerTokenHash: hash,
317
+ approval: { issuer, clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["owner-1"], label: "Example" },
318
+ });
319
+ await missedKv.put("tenant:northwind", JSON.stringify({ id: "id-north", status: "active", region: "default", version: 1 }));
320
+ const missedStart = await missed.dispatchFetch(`${tenant}/oauth/device_authorization`, {
321
+ method: "POST",
322
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.9" },
323
+ body: "client_name=Barry+Bot&client_id=barry",
324
+ });
325
+ const missedDevice = await missedStart.json() as { user_code: string };
326
+ const missedPage = await missed.dispatchFetch(`${tenant}/device?user_code=${missedDevice.user_code}`, {
327
+ headers: { "cf-connecting-ip": "198.51.100.9" },
328
+ });
329
+ const missedHtml = await missedPage.text();
330
+ const missedCsrf = /name="csrf" value="([^"]+)"/.exec(missedHtml)?.[1] ?? "";
331
+ const missedCookie = (missedPage.headers.get("set-cookie") || "").split(";")[0];
332
+ const missedPost = await missed.dispatchFetch(`${tenant}/device`, {
333
+ method: "POST",
334
+ redirect: "manual",
335
+ headers: {
336
+ "content-type": "application/x-www-form-urlencoded",
337
+ origin: tenant,
338
+ cookie: missedCookie,
339
+ "cf-connecting-ip": "198.51.100.9",
340
+ },
341
+ body: new URLSearchParams({ csrf: missedCsrf, user_code: missedDevice.user_code, decision: "oidc" }).toString(),
342
+ });
343
+ assert.equal(missedPost.status, 502);
344
+ assert.match(await missedPost.text(), /The identity provider could not be reached/);
345
+ });
346
+
347
+ test("an apex issuer is fetched with that Host, and a control worker that does not answer still returns", async (t) => {
348
+ const issuer = "https://example.com";
349
+ const tenant = "https://northwind.example.com";
350
+ const control = `
351
+ export default {
352
+ fetch(request) {
353
+ const url = new URL(request.url);
354
+ const host = request.headers.get("host");
355
+ if (url.origin !== ${JSON.stringify(issuer)} || host !== "example.com") return new Promise(() => {});
356
+ if (url.pathname !== "/.well-known/openid-configuration") return new Response("missing", { status: 404 });
357
+ return Response.json({
358
+ issuer: ${JSON.stringify(issuer)},
359
+ authorization_endpoint: ${JSON.stringify(issuer + "/api/oidc/authorize")},
360
+ token_endpoint: ${JSON.stringify(issuer + "/api/oidc/token")},
361
+ jwks_uri: ${JSON.stringify(issuer + "/api/auth/jwks")},
362
+ });
363
+ },
364
+ }
365
+ `;
366
+ const mf = new Miniflare(convertV4MiniflareOptions({
367
+ workers: [
368
+ {
369
+ name: "inbox",
370
+ modules: true,
371
+ script: await SCRIPT,
372
+ compatibilityDate: "2026-10-06",
373
+ routes: ["*://*.example.com/*"],
374
+ d1Databases: { DB: "inbox-apex" },
375
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-apex" },
376
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
377
+ bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret" },
378
+ serviceBindings: { CONTROL_SERVICE: "control" },
379
+ },
380
+ {
381
+ name: "control",
382
+ modules: true,
383
+ script: control,
384
+ compatibilityDate: "2026-10-06",
385
+ routes: ["*://example.com/*"],
386
+ },
387
+ ],
388
+ } as never));
389
+ t.after(() => mf.dispose());
390
+ await mf.ready;
391
+ const kv = await mf.getKVNamespace("TENANT_DIRECTORY", "inbox");
392
+ const ns = await mf.getDurableObjectNamespace("TENANT_DO", "inbox");
393
+ const hash = await sha256("owner-apex");
394
+ const stub = ns.get(ns.idFromName("id-apex")) as unknown as { pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }> };
395
+ assert.equal((await stub.pushConfig({
396
+ version: 1, tenantId: "id-apex", name: "northwind", status: "active", ownerTokenHash: hash,
397
+ approval: { issuer, clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["owner-1"], label: "Example" },
398
+ })).applied, true);
399
+ await kv.put("tenant:northwind", JSON.stringify({ id: "id-apex", status: "active", region: "default", version: 1 }));
400
+ const started = await mf.dispatchFetch(`${tenant}/oauth/device_authorization`, {
401
+ method: "POST",
402
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.20" },
403
+ body: "client_name=Barry+Bot&client_id=barry",
404
+ });
405
+ const device = await started.json() as { user_code?: string };
406
+ assert.equal(started.status, 200, JSON.stringify(device));
407
+ const page = await mf.dispatchFetch(`${tenant}/device?user_code=${device.user_code}`, {
408
+ headers: { "cf-connecting-ip": "198.51.100.20" },
409
+ });
410
+ const html = await page.text();
411
+ assert.match(html, /Alias \(claimed\)/);
412
+ assert.match(html, />barry</);
413
+ assert.equal(html.includes("Client id"), false);
414
+ const csrf = /name="csrf" value="([^"]+)"/.exec(html)?.[1] ?? "";
415
+ const cookie = (page.headers.get("set-cookie") || "").split(";")[0];
416
+ const t0 = Date.now();
417
+ const post = await mf.dispatchFetch(`${tenant}/device`, {
418
+ method: "POST",
419
+ redirect: "manual",
420
+ headers: {
421
+ "content-type": "application/x-www-form-urlencoded",
422
+ origin: tenant,
423
+ cookie,
424
+ "cf-connecting-ip": "198.51.100.20",
425
+ },
426
+ body: new URLSearchParams({ csrf, user_code: device.user_code || "", decision: "oidc" }).toString(),
427
+ });
428
+ assert.ok(Date.now() - t0 < 3000, "a matching Host must not wait out the deadline");
429
+ assert.equal(post.status, 302, await post.text());
430
+ assert.equal(new URL(post.headers.get("location") || "").origin, issuer);
431
+
432
+ const stalled = await start({
433
+ bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret" },
434
+ kvNamespaces: { TENANT_DIRECTORY: "tenants-apex-stall" },
435
+ durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
436
+ serviceBindings: {
437
+ CONTROL_SERVICE: async () => {
438
+ await new Promise((resolve) => setTimeout(resolve, 8000));
439
+ return Response.json({ issuer });
440
+ },
441
+ },
442
+ });
443
+ t.after(() => stalled.dispose());
444
+ await stalled.ready;
445
+ const stalledKv = await stalled.getKVNamespace("TENANT_DIRECTORY");
446
+ const stalledNs = await stalled.getDurableObjectNamespace("TENANT_DO");
447
+ const stalledStub = stalledNs.get(stalledNs.idFromName("id-apex")) as unknown as { pushConfig(body: Record<string, unknown>): Promise<{ applied: boolean }> };
448
+ await stalledStub.pushConfig({
449
+ version: 1, tenantId: "id-apex", name: "northwind", status: "active", ownerTokenHash: hash,
450
+ approval: { issuer, clientId: "client-1", clientSecret: "tenant-secret-value", allowedSubjects: ["owner-1"], label: "Example" },
451
+ });
452
+ await stalledKv.put("tenant:northwind", JSON.stringify({ id: "id-apex", status: "active", region: "default", version: 1 }));
453
+ const stalledStart = await stalled.dispatchFetch(`${tenant}/oauth/device_authorization`, {
454
+ method: "POST",
455
+ headers: { "content-type": "application/x-www-form-urlencoded", "cf-connecting-ip": "198.51.100.21" },
456
+ body: "client_name=Barry+Bot&client_id=barry",
457
+ });
458
+ const stalledDevice = await stalledStart.json() as { user_code: string };
459
+ const stalledPage = await stalled.dispatchFetch(`${tenant}/device?user_code=${stalledDevice.user_code}`, {
460
+ headers: { "cf-connecting-ip": "198.51.100.21" },
461
+ });
462
+ const stalledHtml = await stalledPage.text();
463
+ const stalledCsrf = /name="csrf" value="([^"]+)"/.exec(stalledHtml)?.[1] ?? "";
464
+ const stalledCookie = (stalledPage.headers.get("set-cookie") || "").split(";")[0];
465
+ const stallBegan = Date.now();
466
+ const stalledPost = await Promise.race([
467
+ stalled.dispatchFetch(`${tenant}/device`, {
468
+ method: "POST",
469
+ redirect: "manual",
470
+ headers: {
471
+ "content-type": "application/x-www-form-urlencoded",
472
+ origin: tenant,
473
+ cookie: stalledCookie,
474
+ "cf-connecting-ip": "198.51.100.21",
475
+ },
476
+ body: new URLSearchParams({ csrf: stalledCsrf, user_code: stalledDevice.user_code, decision: "oidc" }).toString(),
477
+ }),
478
+ new Promise<Response>((_, reject) => setTimeout(() => reject(new Error("device post hung")), 7000)),
479
+ ]);
480
+ assert.ok(Date.now() - stallBegan < 7000);
481
+ assert.equal(stalledPost.status, 502);
482
+ assert.match(await stalledPost.text(), /The identity provider could not be reached/);
483
+ });