a2a-exposed 0.7.6 → 0.7.8

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
@@ -175,6 +175,10 @@ Control plane account (only when that deployment's login is on)
175
175
  browser can follow the redirect. Buttons say Continue with that
176
176
  provider. Until the check finishes they stay muted and the page says
177
177
  Waiting for the check…. A reused token says The check expired. Please try again.
178
+ After sign-in, /app shows the account and its agents. Sign out is on that
179
+ page. Creating an agent shows the owner token once. That page is not stored.
180
+ FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
181
+ Unset, there is no limit. The database rejects another insert.
178
182
  The control Worker logs social_sign_in_failed before the
179
183
  provider page, social_sign_in_timeout when that sign-in does not
180
184
  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.6",
3
+ "version": "0.7.8",
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,59 @@ 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
+ /**
129
+ * Discovery, token and JWKS for an issuer on this tenant's zone go through CONTROL_SERVICE.
130
+ * A public fetch of that zone can miss the control Worker (wildcard route, 522, or the site Worker).
131
+ * Any other issuer, and a deploy with the binding unset, uses fetch.
132
+ */
133
+ async function idpFetch(ctx: TenantContext, url: string, init: RequestInit, op: IdpOp): Promise<Response> {
134
+ let host = "";
135
+ try { host = new URL(url).hostname; } catch { /* logged as a failed fetch */ }
136
+ const binding = ctx.controlService();
137
+ const viaBinding = !!(binding && host && reachesControl(pageHostname(ctx), host, ctx.gates.tenantDomain));
138
+ try {
139
+ // A service binding rejects `redirect` and `signal` on the request (they do not cross the
140
+ // binding). The timeout still applies here. Redirects stay an error: the binding does not follow them.
141
+ const res = viaBinding ? await bindingFetch(binding!, url, init) : await fetch(url, init);
142
+ if (!res.ok) logUnreachable(host, op, res.status);
143
+ return res;
144
+ } catch (error) {
145
+ logUnreachable(host, op, 0, error);
146
+ throw new Error("http");
147
+ }
148
+ }
149
+
150
+ async function bindingFetch(binding: NonNullable<ReturnType<TenantContext["controlService"]>>, url: string, init: RequestInit): Promise<Response> {
151
+ const headers = new Headers(init.headers);
152
+ const req = new Request(url, { method: init.method || "GET", headers, body: init.body });
153
+ let timer: ReturnType<typeof setTimeout> | undefined;
154
+ try {
155
+ return await Promise.race([
156
+ binding.fetch(req),
157
+ new Promise<Response>((_, reject) => {
158
+ timer = setTimeout(() => reject(new DOMException("The operation was aborted", "AbortError")), FETCH_MS);
159
+ }),
160
+ ]);
161
+ } finally {
162
+ if (timer !== undefined) clearTimeout(timer);
163
+ }
164
+ }
165
+
166
+ async function getJson(ctx: TenantContext, url: string, op: IdpOp): Promise<any> {
167
+ const res = await idpFetch(ctx, url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(FETCH_MS) }, op);
88
168
  if (!res.ok) throw new Error("http");
89
169
  return readJson(res, 65536);
90
170
  }
@@ -95,8 +175,8 @@ interface Discovery {
95
175
  jwks_uri: string;
96
176
  }
97
177
 
98
- async function discover(issuer: string): Promise<Discovery> {
99
- const doc = await getJson(`${issuerBase(issuer)}/.well-known/openid-configuration`);
178
+ async function discover(ctx: TenantContext, issuer: string): Promise<Discovery> {
179
+ const doc = await getJson(ctx, `${issuerBase(issuer)}/.well-known/openid-configuration`, "discovery");
100
180
  if (issuerBase(String(doc?.issuer || "")) !== issuerBase(issuer)) throw new Error("issuer");
101
181
  return {
102
182
  authorization_endpoint: httpsEndpoint(doc.authorization_endpoint),
@@ -112,7 +192,7 @@ export async function startOidc(ctx: TenantContext, o: {
112
192
  const gate = ctx.gates.approvalOidc;
113
193
  if (!approvalPolicy(gate).oidc || !ctx.approvalClientSecret()) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config" };
114
194
  let disc: Discovery;
115
- try { disc = await discover(gate.issuer); }
195
+ try { disc = await discover(ctx, gate.issuer); }
116
196
  catch { return { ok: false, error: UNREACHABLE, reason: "discovery" }; }
117
197
  const { verifier, challenge } = await pkce();
118
198
  const state = b64url(crypto.getRandomValues(new Uint8Array(32)));
@@ -217,7 +297,7 @@ export async function finishOidc(ctx: TenantContext, o: {
217
297
  const secret = ctx.approvalClientSecret();
218
298
  if (!approvalPolicy(gate).oidc || !secret) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
219
299
  let disc: Discovery;
220
- try { disc = await discover(gate.issuer); }
300
+ try { disc = await discover(ctx, gate.issuer); }
221
301
  catch { return { ok: false, error: UNREACHABLE, reason: "discovery", userCode }; }
222
302
  let idToken = "";
223
303
  try {
@@ -225,16 +305,16 @@ export async function finishOidc(ctx: TenantContext, o: {
225
305
  grant_type: "authorization_code", code: o.code, redirect_uri: o.redirectUri,
226
306
  client_id: gate.clientId, client_secret: secret, code_verifier: row.verifier,
227
307
  });
228
- const res = await fetch(disc.token_endpoint, {
308
+ const res = await idpFetch(ctx, disc.token_endpoint, {
229
309
  method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
230
310
  body, redirect: "error", signal: AbortSignal.timeout(FETCH_MS),
231
- });
311
+ }, "token");
232
312
  if (!res.ok) return { ok: false, error: UNREACHABLE, reason: "token", userCode };
233
313
  const tok = await readJson(res, 65536);
234
314
  idToken = typeof tok.id_token === "string" ? tok.id_token : "";
235
315
  } catch { return { ok: false, error: UNREACHABLE, reason: "token", userCode }; }
236
316
  let jwks: { keys?: any[] };
237
- try { jwks = await getJson(disc.jwks_uri); }
317
+ try { jwks = await getJson(ctx, disc.jwks_uri, "jwks"); }
238
318
  catch { return { ok: false, error: UNREACHABLE, reason: "jwks", userCode }; }
239
319
  const verified = await verifyIdToken(idToken, jwks, { iss: gate.issuer, aud: gate.clientId, nonce: row.nonce, subjects: gate.allowedSubjects });
240
320
  if ("reason" in verified) return { ok: false, error: FAIL, reason: verified.reason, 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,118 @@ 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
+ });
@@ -219,3 +219,127 @@ 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
+ });