a2a-exposed 0.7.8 → 0.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/main.mjs +2 -0
- package/package.json +1 -1
- package/worker/src/approval-oidc.ts +81 -17
- package/worker/src/index.ts +23 -11
- package/worker/src/pairing.ts +25 -5
- package/worker/test/approval-oidc.test.ts +134 -1
- package/worker/test/hosted.test.ts +138 -0
package/lib/main.mjs
CHANGED
|
@@ -179,6 +179,8 @@ Control plane account (only when that deployment's login is on)
|
|
|
179
179
|
page. Creating an agent shows the owner token once. That page is not stored.
|
|
180
180
|
FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
|
|
181
181
|
Unset, there is no limit. The database rejects another insert.
|
|
182
|
+
SIGNUP_ALLOWLIST, when set, admits only those verified emails. Unset, sign-in stays open.
|
|
183
|
+
A refusal returns to /app?error=invite and the page says Sign-in is invite-only for now.
|
|
182
184
|
The control Worker logs social_sign_in_failed before the
|
|
183
185
|
provider page, social_sign_in_timeout when that sign-in does not
|
|
184
186
|
return, and oauth_callback_failed when the provider sends the browser
|
package/package.json
CHANGED
|
@@ -23,6 +23,18 @@ export function issuerHost(issuer: string): string {
|
|
|
23
23
|
try { return new URL(issuer).host; } catch { return ""; }
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Origin to list in `form-action` when OpenID Connect approval is on.
|
|
28
|
+
* Scheme, host, and port only. A path, userinfo, or a non-https issuer is not included.
|
|
29
|
+
*/
|
|
30
|
+
export function formActionOrigin(issuer: string): string {
|
|
31
|
+
try {
|
|
32
|
+
const u = new URL(issuer);
|
|
33
|
+
if (u.protocol !== "https:" || u.username || u.password) return "";
|
|
34
|
+
return u.origin;
|
|
35
|
+
} catch { return ""; }
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
export function issuerBase(issuer: string): string {
|
|
27
39
|
return issuer.replace(/\/+$/, "");
|
|
28
40
|
}
|
|
@@ -125,46 +137,93 @@ function logUnreachable(host: string, op: IdpOp, status: number, error?: unknown
|
|
|
125
137
|
console.log(JSON.stringify(kw));
|
|
126
138
|
}
|
|
127
139
|
|
|
140
|
+
function startDeadline(): { signal: AbortSignal; stop: () => void } {
|
|
141
|
+
const ac = new AbortController();
|
|
142
|
+
const timer = setTimeout(() => ac.abort(), FETCH_MS);
|
|
143
|
+
return { signal: ac.signal, stop: () => clearTimeout(timer) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function aborted(signal: AbortSignal): Promise<never> {
|
|
147
|
+
if (signal.aborted) return Promise.reject(new DOMException("The operation was aborted", "AbortError"));
|
|
148
|
+
return new Promise((_, reject) => {
|
|
149
|
+
signal.addEventListener("abort", () => reject(new DOMException("The operation was aborted", "AbortError")), { once: true });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** An error page is cancelled and not read. Only a successful body is buffered, and only up to `max`. */
|
|
154
|
+
function dropBody(res: Response): void {
|
|
155
|
+
try { void res.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function takeBody(res: Response, signal: AbortSignal, max: number): Promise<Response> {
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
dropBody(res);
|
|
161
|
+
return new Response(null, { status: res.status, statusText: res.statusText });
|
|
162
|
+
}
|
|
163
|
+
const len = Number(res.headers.get("content-length") || 0);
|
|
164
|
+
if (len > max) {
|
|
165
|
+
dropBody(res);
|
|
166
|
+
throw new Error("large");
|
|
167
|
+
}
|
|
168
|
+
const text = await Promise.race([res.text(), aborted(signal)]);
|
|
169
|
+
if (text.length > max) throw new Error("large");
|
|
170
|
+
const headers = new Headers(res.headers);
|
|
171
|
+
headers.delete("content-length");
|
|
172
|
+
return new Response(text, { status: res.status, statusText: res.statusText, headers });
|
|
173
|
+
}
|
|
174
|
+
|
|
128
175
|
/**
|
|
129
176
|
* Discovery, token and JWKS for an issuer on this tenant's zone go through CONTROL_SERVICE.
|
|
130
177
|
* A public fetch of that zone can miss the control Worker (wildcard route, 522, or the site Worker).
|
|
131
178
|
* Any other issuer, and a deploy with the binding unset, uses fetch.
|
|
179
|
+
* The binding request sets Host to the issuer host. Without that header the control Worker on the
|
|
180
|
+
* zone apex sees no Host, and the subrequest does not finish. Every call, binding or fetch, stops
|
|
181
|
+
* after FETCH_MS and the page says the provider could not be reached.
|
|
132
182
|
*/
|
|
133
183
|
async function idpFetch(ctx: TenantContext, url: string, init: RequestInit, op: IdpOp): Promise<Response> {
|
|
134
184
|
let host = "";
|
|
135
185
|
try { host = new URL(url).hostname; } catch { /* logged as a failed fetch */ }
|
|
136
186
|
const binding = ctx.controlService();
|
|
137
187
|
const viaBinding = !!(binding && host && reachesControl(pageHostname(ctx), host, ctx.gates.tenantDomain));
|
|
188
|
+
const { signal, stop } = startDeadline();
|
|
138
189
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
190
|
+
const res = await Promise.race([
|
|
191
|
+
viaBinding ? bindingFetch(binding!, url, init, signal) : publicFetch(url, init, signal),
|
|
192
|
+
aborted(signal),
|
|
193
|
+
]);
|
|
194
|
+
const full = await Promise.race([takeBody(res, signal, 65536), aborted(signal)]);
|
|
195
|
+
if (!full.ok) logUnreachable(host, op, full.status);
|
|
196
|
+
return full;
|
|
144
197
|
} catch (error) {
|
|
145
198
|
logUnreachable(host, op, 0, error);
|
|
146
199
|
throw new Error("http");
|
|
200
|
+
} finally {
|
|
201
|
+
stop();
|
|
147
202
|
}
|
|
148
203
|
}
|
|
149
204
|
|
|
150
|
-
|
|
205
|
+
function publicFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
|
|
206
|
+
return fetch(url, { method: init.method, headers: init.headers, body: init.body, redirect: "error", signal });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function bindingFetch(binding: NonNullable<ReturnType<TenantContext["controlService"]>>, url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
|
|
151
210
|
const headers = new Headers(init.headers);
|
|
211
|
+
// `new Request(url)` does not keep Host across a service binding. The apex issuer is then
|
|
212
|
+
// a request with no Host, which does not match the control Worker's route or ISSUER, and the
|
|
213
|
+
// subrequest never completes. Set Host to the URL host. `redirect` and `signal` stay off the
|
|
214
|
+
// Request (they do not cross the binding). `signal` is passed to fetch so the runtime cancels it.
|
|
215
|
+
headers.set("host", new URL(url).host);
|
|
152
216
|
const req = new Request(url, { method: init.method || "GET", headers, body: init.body });
|
|
153
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
154
217
|
try {
|
|
155
|
-
return await
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}),
|
|
160
|
-
]);
|
|
161
|
-
} finally {
|
|
162
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
218
|
+
return await binding.fetch(req, { signal });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error instanceof TypeError && !signal.aborted) return binding.fetch(req);
|
|
221
|
+
throw error;
|
|
163
222
|
}
|
|
164
223
|
}
|
|
165
224
|
|
|
166
225
|
async function getJson(ctx: TenantContext, url: string, op: IdpOp): Promise<any> {
|
|
167
|
-
const res = await idpFetch(ctx, url, { headers: { accept: "application/json" }
|
|
226
|
+
const res = await idpFetch(ctx, url, { headers: { accept: "application/json" } }, op);
|
|
168
227
|
if (!res.ok) throw new Error("http");
|
|
169
228
|
return readJson(res, 65536);
|
|
170
229
|
}
|
|
@@ -194,6 +253,11 @@ export async function startOidc(ctx: TenantContext, o: {
|
|
|
194
253
|
let disc: Discovery;
|
|
195
254
|
try { disc = await discover(ctx, gate.issuer); }
|
|
196
255
|
catch { return { ok: false, error: UNREACHABLE, reason: "discovery" }; }
|
|
256
|
+
// form-action lists only the issuer origin. A sign-in address on another origin would be blocked by the browser.
|
|
257
|
+
const authOrigin = formActionOrigin(disc.authorization_endpoint);
|
|
258
|
+
if (!authOrigin || authOrigin !== formActionOrigin(gate.issuer)) {
|
|
259
|
+
return { ok: false, error: "The identity provider's sign-in address is not on the issuer origin. Nothing was approved.", reason: "endpoint_origin" };
|
|
260
|
+
}
|
|
197
261
|
const { verifier, challenge } = await pkce();
|
|
198
262
|
const state = b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
199
263
|
const nonce = b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
@@ -307,7 +371,7 @@ export async function finishOidc(ctx: TenantContext, o: {
|
|
|
307
371
|
});
|
|
308
372
|
const res = await idpFetch(ctx, disc.token_endpoint, {
|
|
309
373
|
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
310
|
-
body,
|
|
374
|
+
body,
|
|
311
375
|
}, "token");
|
|
312
376
|
if (!res.ok) return { ok: false, error: UNREACHABLE, reason: "token", userCode };
|
|
313
377
|
const tok = await readJson(res, 65536);
|
package/worker/src/index.ts
CHANGED
|
@@ -1325,6 +1325,16 @@ function approvalPageBits(ctx: TenantContext): { oidc?: { label: string }; passw
|
|
|
1325
1325
|
return bits;
|
|
1326
1326
|
}
|
|
1327
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* The OpenID Connect issuer origin, when that approval method is on.
|
|
1330
|
+
* A form on this host that 302s to the issuer is checked against this origin by Chrome's `form-action`.
|
|
1331
|
+
*/
|
|
1332
|
+
function oidcFormOrigins(ctx: TenantContext): string[] {
|
|
1333
|
+
if (!O.approvalPolicy(ctx.gates.approvalOidc).oidc) return [];
|
|
1334
|
+
const origin = O.formActionOrigin(ctx.gates.approvalOidc.issuer);
|
|
1335
|
+
return origin ? [origin] : [];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1328
1338
|
const redirect302 = (location: string) => new Response(null, { status: 302, headers: { location, "cache-control": "no-store", pragma: "no-cache", "referrer-policy": "no-referrer" } });
|
|
1329
1339
|
|
|
1330
1340
|
async function approvalPassword(ctx: TenantContext): Promise<P.PasswordRecord | null> {
|
|
@@ -1350,7 +1360,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
|
|
|
1350
1360
|
const pw = await approvalPassword(ctx);
|
|
1351
1361
|
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf, passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
1352
1362
|
const send = (status = 200) => {
|
|
1353
|
-
const h: Record<string, string> = P.pageHeaders(nonce);
|
|
1363
|
+
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1354
1364
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1355
1365
|
return new Response(P.devicePage(model, nonce), { status, headers: h });
|
|
1356
1366
|
};
|
|
@@ -1408,7 +1418,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
|
|
|
1408
1418
|
if (!started.ok) {
|
|
1409
1419
|
log("oidc_rejected", { kind: "device", reason: started.reason });
|
|
1410
1420
|
await show(form.user_code, { kind: "error", text: started.error });
|
|
1411
|
-
return send(started.reason === "config" ? 400 : 502);
|
|
1421
|
+
return send(started.reason === "config" || started.reason === "endpoint_origin" ? 400 : 502);
|
|
1412
1422
|
}
|
|
1413
1423
|
log("oidc_start", { kind: "device", issuer: O.issuerHost(ctx.gates.approvalOidc.issuer) });
|
|
1414
1424
|
return redirect302(started.location);
|
|
@@ -1455,7 +1465,7 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL): P
|
|
|
1455
1465
|
const pw = await approvalPassword(ctx);
|
|
1456
1466
|
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
1457
1467
|
const send = (status = 200) => {
|
|
1458
|
-
const h: Record<string, string> = P.pageHeaders(nonce);
|
|
1468
|
+
const h: Record<string, string> = P.pageHeaders(nonce, oidcFormOrigins(ctx));
|
|
1459
1469
|
h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1460
1470
|
return new Response(P.devicePage(model, nonce), { status, headers: h });
|
|
1461
1471
|
};
|
|
@@ -1497,7 +1507,7 @@ async function liveSetup(ctx: TenantContext, token: string): Promise<{ rec: Setu
|
|
|
1497
1507
|
return A.timingSafeEqualStr(await A.sha256(token), s.rec.hash) ? s : null;
|
|
1498
1508
|
}
|
|
1499
1509
|
|
|
1500
|
-
/** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame
|
|
1510
|
+
/** GET/POST /device/setup: set or change the approval password from a one-time link. Same frame and CSRF as /device. The form posts only to this host, so form-action stays 'self'. */
|
|
1501
1511
|
async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1502
1512
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1503
1513
|
const csrfName = "a2a_device_csrf";
|
|
@@ -1897,10 +1907,9 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
|
|
|
1897
1907
|
const q = req.method === "GET" ? Object.fromEntries(url.searchParams) : form;
|
|
1898
1908
|
const c = await checkAuthorize(ctx, q, ip);
|
|
1899
1909
|
const pw = await approvalPassword(ctx);
|
|
1900
|
-
const page = (status: number, body: string,
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
if (formOrigin) h["content-security-policy"] = h["content-security-policy"].replace("form-action 'self'", `form-action 'self' ${formOrigin}`);
|
|
1910
|
+
const page = (status: number, body: string, formOrigins: string[] = []) => {
|
|
1911
|
+
// Approve and deny 302 to the client. OpenID Connect 302s to the issuer. Chrome checks both against form-action.
|
|
1912
|
+
const h: Record<string, string> = P.pageHeaders(nonce, formOrigins);
|
|
1904
1913
|
h["set-cookie"] = `${csrfName}=${csrf}; Path=/oauth/authorize; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1905
1914
|
return new Response(P.pageShell("Connect an MCP client", nonce, body), { status, headers: h });
|
|
1906
1915
|
};
|
|
@@ -1909,7 +1918,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
|
|
|
1909
1918
|
const origin = new URL(c.redirect!).origin;
|
|
1910
1919
|
const show = (status: number, notice?: M.ConsentModel["notice"]) => page(status, M.consentBody({ agentName: ctx.AGENT_NAME || "this A2A inbox", cli: cliCommand(ctx),
|
|
1911
1920
|
csrf, clientName: c.client!.client_name || "", clientIdHost: c.client!.cimd ? new URL(c.client!.client_id).host : undefined,
|
|
1912
|
-
redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), origin);
|
|
1921
|
+
redirectUri: c.redirect!, params: c.params, passwordSet: !!pw, ...approvalPageBits(ctx), notice }), [origin, ...oidcFormOrigins(ctx)]);
|
|
1913
1922
|
if (req.method === "GET") return show(200);
|
|
1914
1923
|
const reqOrigin = req.headers.get("origin");
|
|
1915
1924
|
if ((reqOrigin && reqOrigin !== "null" && reqOrigin !== new URL(ctx.PUBLIC_URL).origin && reqOrigin !== url.origin) || fresh || !form.csrf || !A.timingSafeEqualStr(form.csrf, csrf))
|
|
@@ -1925,7 +1934,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
|
|
|
1925
1934
|
});
|
|
1926
1935
|
if (!started.ok) {
|
|
1927
1936
|
log("oidc_rejected", { kind: "mcp", reason: started.reason });
|
|
1928
|
-
return show(started.reason === "config" ? 400 : 502, { kind: "error", text: started.error });
|
|
1937
|
+
return show(started.reason === "config" || started.reason === "endpoint_origin" ? 400 : 502, { kind: "error", text: started.error });
|
|
1929
1938
|
}
|
|
1930
1939
|
log("oidc_start", { kind: "mcp", issuer: O.issuerHost(ctx.gates.approvalOidc.issuer) });
|
|
1931
1940
|
return redirect302(started.location);
|
|
@@ -1963,7 +1972,10 @@ async function issueMcpCode(ctx: TenantContext, clientName: string, redirect: st
|
|
|
1963
1972
|
return { ok: true, response: redirectWith(ctx, redirect, { code, state: params.state }) };
|
|
1964
1973
|
}
|
|
1965
1974
|
|
|
1966
|
-
/** GET /oauth/authorize/oidc/callback. Same state binding as the pairing callback; then the existing code redirect.
|
|
1975
|
+
/** GET /oauth/authorize/oidc/callback. Same state binding as the pairing callback; then the existing code redirect.
|
|
1976
|
+
* The identity provider navigates here with a GET. The code redirect is that navigation's 302, not a form post, so form-action does not apply to the client origin.
|
|
1977
|
+
* The error page has no form.
|
|
1978
|
+
*/
|
|
1967
1979
|
async function mcpOidcCallback(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1968
1980
|
const nonce = P.randomB64();
|
|
1969
1981
|
const fail = (status: number, text: string) => new Response(P.pageShell("Connect an MCP client", nonce, `<p class="n error">${P.esc(text)}</p>`), { status, headers: P.pageHeaders(nonce) });
|
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) : "")}">
|
|
@@ -308,11 +308,31 @@ export function setupPage(m: SetupModel, nonce: string): string {
|
|
|
308
308
|
</form>`);
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
/**
|
|
312
|
-
export function
|
|
311
|
+
/** An http(s) origin (scheme, host, and port) with no userinfo. Anything else, including a path, is dropped. */
|
|
312
|
+
export function cspFormOrigin(raw: string): string {
|
|
313
|
+
try {
|
|
314
|
+
const u = new URL(raw);
|
|
315
|
+
if ((u.protocol !== "https:" && u.protocol !== "http:") || u.username || u.password) return "";
|
|
316
|
+
if (u.origin !== raw) return "";
|
|
317
|
+
return u.origin;
|
|
318
|
+
} catch { return ""; }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Security headers for the /device page: strict CSP (no scripts; only this page's own style), no framing, no caching.
|
|
323
|
+
* `formOrigins` are extra `form-action` origins. Chrome applies `form-action` to a form POST's redirect target,
|
|
324
|
+
* so a page whose POST answers 302 to another origin must list that origin here. With none, the directive stays `form-action 'self'`.
|
|
325
|
+
*/
|
|
326
|
+
export function pageHeaders(nonce: string, formOrigins: readonly string[] = []): Record<string, string> {
|
|
327
|
+
const extra: string[] = [];
|
|
328
|
+
for (const raw of formOrigins) {
|
|
329
|
+
const origin = cspFormOrigin(raw);
|
|
330
|
+
if (origin && !extra.includes(origin)) extra.push(origin);
|
|
331
|
+
}
|
|
332
|
+
const form = ["'self'", ...extra].join(" ");
|
|
313
333
|
return {
|
|
314
334
|
"content-type": "text/html; charset=utf-8",
|
|
315
|
-
"content-security-policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action
|
|
335
|
+
"content-security-policy": `default-src 'none'; style-src 'nonce-${nonce}'; form-action ${form}; frame-ancestors 'none'; base-uri 'none'`,
|
|
316
336
|
"x-frame-options": "DENY", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer",
|
|
317
337
|
"cache-control": "no-store", "cross-origin-opener-policy": "same-origin",
|
|
318
338
|
};
|
|
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
6
|
import worker, { dispatch } from "../src/index.ts";
|
|
7
7
|
import * as M from "../src/mcp.ts";
|
|
8
|
-
import { approvalPolicy, hostOnZone, reachesControl, sameZoneHost } from "../src/approval-oidc.ts";
|
|
8
|
+
import { approvalPolicy, formActionOrigin, hostOnZone, reachesControl, sameZoneHost } from "../src/approval-oidc.ts";
|
|
9
9
|
import { hostedTenantContext, parseGates, type WorkerBindings } from "../src/tenancy.ts";
|
|
10
10
|
import { d1 } from "./d1.ts";
|
|
11
11
|
|
|
@@ -156,6 +156,70 @@ test("configured /device redirects to the issuer with PKCE and approves on a val
|
|
|
156
156
|
assert.equal(list.data.oidcIssuer, "idp.example");
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
function formAction(headers: Headers): string {
|
|
160
|
+
return /form-action ([^;]+)/.exec(headers.get("content-security-policy") || "")?.[1] || "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("form-action lists only the OpenID Connect issuer origin, and only when that approval is on", async (t) => {
|
|
164
|
+
assert.equal(formActionOrigin(ISSUER), ISSUER);
|
|
165
|
+
assert.equal(formActionOrigin(`${ISSUER}/realms/app`), ISSUER);
|
|
166
|
+
assert.equal(formActionOrigin("https://idp.example:8443/realms/app"), "https://idp.example:8443");
|
|
167
|
+
assert.equal(formActionOrigin("https://user:secret@idp.example"), "");
|
|
168
|
+
assert.equal(formActionOrigin("http://idp.example"), "");
|
|
169
|
+
|
|
170
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
171
|
+
const d = await s.start();
|
|
172
|
+
const g = await s.page(d.data.user_code);
|
|
173
|
+
assert.equal(formAction(g.headers), `'self' ${ISSUER}`);
|
|
174
|
+
assert.equal(g.headers.get("content-security-policy")!.includes(`${ISSUER}/`), false);
|
|
175
|
+
|
|
176
|
+
const downFetch = globalThis.fetch;
|
|
177
|
+
globalThis.fetch = (async () => { throw new Error("down"); }) as typeof fetch;
|
|
178
|
+
const failed = await s.postOidc(d.data.user_code);
|
|
179
|
+
globalThis.fetch = downFetch;
|
|
180
|
+
assert.equal(failed.post.status, 502, failed.post.text);
|
|
181
|
+
assert.match(failed.post.text, /Approve with idp\.example/);
|
|
182
|
+
assert.equal(formAction(failed.post.headers), `'self' ${ISSUER}`);
|
|
183
|
+
|
|
184
|
+
const back = await s.call("GET", "/device/oidc/callback?error=access_denied");
|
|
185
|
+
assert.equal(back.status, 400);
|
|
186
|
+
assert.equal(formAction(back.headers), `'self' ${ISSUER}`);
|
|
187
|
+
|
|
188
|
+
const setupPage = await s.call("GET", "/device/setup");
|
|
189
|
+
assert.equal(formAction(setupPage.headers), "'self'");
|
|
190
|
+
const land = await s.call("GET", "/", { headers: { accept: "text/html" } });
|
|
191
|
+
assert.match(land.headers.get("content-security-policy") || "", /form-action 'none'/);
|
|
192
|
+
|
|
193
|
+
const pw = setup({ ...oidcEnv, APPROVAL_METHODS: "password" }); t.after(pw.restore);
|
|
194
|
+
const d2 = await pw.start();
|
|
195
|
+
const g2 = await pw.page(d2.data.user_code);
|
|
196
|
+
assert.equal(g2.text.includes("Approve with"), false);
|
|
197
|
+
assert.equal(formAction(g2.headers), "'self'");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("an authorization endpoint on another origin is not started", async (t) => {
|
|
201
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
202
|
+
const inner = globalThis.fetch;
|
|
203
|
+
globalThis.fetch = (async (url: any, init: any) => {
|
|
204
|
+
const u = new URL(String(url));
|
|
205
|
+
if (u.origin === ISSUER && u.pathname === "/.well-known/openid-configuration") {
|
|
206
|
+
return Response.json({
|
|
207
|
+
issuer: ISSUER, authorization_endpoint: "https://login.example/authorize",
|
|
208
|
+
token_endpoint: `${ISSUER}/token`, jwks_uri: `${ISSUER}/jwks`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return inner(url, init);
|
|
212
|
+
}) as typeof fetch;
|
|
213
|
+
const d = await s.start();
|
|
214
|
+
const { post } = await s.postOidc(d.data.user_code as string);
|
|
215
|
+
assert.equal(post.status, 400, post.text);
|
|
216
|
+
assert.match(post.text, /not on the issuer origin/);
|
|
217
|
+
assert.equal(post.headers.get("location"), null);
|
|
218
|
+
const tok = await s.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
|
|
219
|
+
assert.equal(tok.data.error, "authorization_pending");
|
|
220
|
+
assert.equal(s.logs.join("\n").includes("login.example"), false);
|
|
221
|
+
});
|
|
222
|
+
|
|
159
223
|
test("wrong sub, wrong nonce and expired exp do not approve", async (t) => {
|
|
160
224
|
const s = setup(oidcEnv); t.after(s.restore);
|
|
161
225
|
const bad = async (over: Record<string, unknown>) => {
|
|
@@ -314,6 +378,7 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
|
|
|
314
378
|
const g = await s.call("GET", `/oauth/authorize?${new URLSearchParams(q)}`);
|
|
315
379
|
assert.match(g.text, /Continue with idp\.example/);
|
|
316
380
|
assert.match(g.text, /No approval password is set yet/);
|
|
381
|
+
assert.equal(/form-action ([^;]+)/.exec(g.headers.get("content-security-policy") || "")?.[1], `'self' https://claude.ai ${ISSUER}`);
|
|
317
382
|
const cookie = (g.headers.get("set-cookie") || "").split(";")[0];
|
|
318
383
|
const csrf = /name="csrf" value="([^"]+)"/.exec(g.text)![1];
|
|
319
384
|
const post = await s.call("POST", "/oauth/authorize", { form: { ...q, csrf, decision: "oidc" }, cookie, headers: { origin: BASE } });
|
|
@@ -323,6 +388,10 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
|
|
|
323
388
|
s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
|
|
324
389
|
const back = await s.call("GET", `/oauth/authorize/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
325
390
|
assert.equal(back.status, 302, back.text);
|
|
391
|
+
const callbackError = await s.call("GET", "/oauth/authorize/oidc/callback?error=access_denied");
|
|
392
|
+
assert.equal(callbackError.status, 400);
|
|
393
|
+
assert.equal(callbackError.text.includes("<form"), false);
|
|
394
|
+
assert.equal(/form-action ([^;]+)/.exec(callbackError.headers.get("content-security-policy") || "")?.[1], "'self'");
|
|
326
395
|
const client = new URL(back.headers.get("location")!);
|
|
327
396
|
assert.equal(client.origin, "https://claude.ai");
|
|
328
397
|
assert.equal(client.searchParams.get("state"), "st-1");
|
|
@@ -447,3 +516,67 @@ test("a failed same-zone fetch logs idp_unreachable with status and host", async
|
|
|
447
516
|
assert.equal(JSON.stringify(last).includes("connect failed"), false);
|
|
448
517
|
assert.equal(JSON.stringify(last).includes(SECRET), false);
|
|
449
518
|
});
|
|
519
|
+
|
|
520
|
+
test("an identity-provider error page is cancelled and not buffered", async (t) => {
|
|
521
|
+
const s = setup({ ...oidcEnv, APPROVAL_OIDC_ISSUER: ZONE }); t.after(s.restore);
|
|
522
|
+
let pulls = 0;
|
|
523
|
+
let cancelled = false;
|
|
524
|
+
s.env.CONTROL_SERVICE = {
|
|
525
|
+
async fetch() {
|
|
526
|
+
return new Response(new ReadableStream({
|
|
527
|
+
pull(controller) {
|
|
528
|
+
pulls++;
|
|
529
|
+
controller.enqueue(new Uint8Array(64 * 1024));
|
|
530
|
+
},
|
|
531
|
+
cancel() { cancelled = true; },
|
|
532
|
+
}), { status: 522 });
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
globalThis.fetch = (async () => { throw new Error("public fetch must not run"); }) as typeof fetch;
|
|
536
|
+
const d = await s.start();
|
|
537
|
+
const started = Date.now();
|
|
538
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
539
|
+
assert.ok(Date.now() - started < 1000, "error headers should return without reading the body");
|
|
540
|
+
assert.equal(post.status, 502);
|
|
541
|
+
assert.match(post.text, /The identity provider could not be reached/);
|
|
542
|
+
assert.equal(cancelled, true);
|
|
543
|
+
assert.ok(pulls < 3, `buffered ${pulls} chunks`);
|
|
544
|
+
const line = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).find((entry) => entry?.event === "idp_unreachable");
|
|
545
|
+
assert.equal(line?.status, 522);
|
|
546
|
+
assert.equal(line?.op, "discovery");
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("a same-zone issuer that does not answer is given up, and the binding request carries the issuer host", async (t) => {
|
|
550
|
+
const issuer = "https://example.com";
|
|
551
|
+
const s = setup({
|
|
552
|
+
...oidcEnv, APPROVAL_OIDC_ISSUER: issuer, PUBLIC_URL: "https://northwind.example.com", TENANT_DOMAIN: "example.com",
|
|
553
|
+
});
|
|
554
|
+
t.after(s.restore);
|
|
555
|
+
let host = "";
|
|
556
|
+
s.env.CONTROL_SERVICE = {
|
|
557
|
+
async fetch(input: RequestInfo | URL) {
|
|
558
|
+
const req = input instanceof Request ? input : new Request(input);
|
|
559
|
+
host = req.headers.get("host") || "";
|
|
560
|
+
assert.equal(new URL(req.url).origin, issuer);
|
|
561
|
+
await new Promise((resolve) => setTimeout(resolve, 8000));
|
|
562
|
+
return Response.json({
|
|
563
|
+
issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, jwks_uri: `${issuer}/jwks`,
|
|
564
|
+
});
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
globalThis.fetch = (async () => { throw new Error("public fetch must not run"); }) as typeof fetch;
|
|
568
|
+
const d = await s.start();
|
|
569
|
+
const started = Date.now();
|
|
570
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
571
|
+
const waited = Date.now() - started;
|
|
572
|
+
assert.equal(host, "example.com");
|
|
573
|
+
assert.ok(waited < 7000, `waited ${waited}ms`);
|
|
574
|
+
assert.equal(post.status, 502, post.text);
|
|
575
|
+
assert.match(post.text, /The identity provider could not be reached/);
|
|
576
|
+
const line = s.logs.map((entry) => { try { return JSON.parse(entry); } catch { return null; } }).find((entry) => entry?.event === "idp_unreachable");
|
|
577
|
+
assert.equal(line?.op, "discovery");
|
|
578
|
+
assert.equal(line?.status, 0);
|
|
579
|
+
assert.equal(line?.host, "example.com");
|
|
580
|
+
assert.equal(line?.error, "AbortError");
|
|
581
|
+
assert.equal(JSON.stringify(line).includes("The operation was aborted"), false);
|
|
582
|
+
});
|
|
@@ -343,3 +343,141 @@ test("hosted approval reaches a same-zone issuer through CONTROL_SERVICE", async
|
|
|
343
343
|
assert.equal(missedPost.status, 502);
|
|
344
344
|
assert.match(await missedPost.text(), /The identity provider could not be reached/);
|
|
345
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
|
+
});
|