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 +4 -0
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +5 -0
- package/worker/deploy.env.example +3 -0
- package/worker/src/approval-oidc.ts +89 -9
- package/worker/src/tenancy.ts +21 -1
- package/worker/test/approval-oidc.test.ts +119 -1
- package/worker/test/hosted.test.ts +124 -0
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
|
@@ -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
|
-
|
|
87
|
-
|
|
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
|
|
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 };
|
package/worker/src/tenancy.ts
CHANGED
|
@@ -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
|
+
});
|