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 +1 -1
- package/worker/cloudflare.config.ts +5 -0
- package/worker/deploy.env.example +3 -0
- package/worker/src/approval-oidc.ts +137 -10
- package/worker/src/pairing.ts +2 -2
- package/worker/src/tenancy.ts +21 -1
- package/worker/test/approval-oidc.test.ts +183 -1
- package/worker/test/hosted.test.ts +262 -0
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,106 @@ 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
|
+
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
|
|
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,
|
|
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 };
|
package/worker/src/pairing.ts
CHANGED
|
@@ -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 ? [["
|
|
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 <label></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 <label></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) : "")}">
|
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,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
|
+
});
|