a2a-exposed 0.7.15 → 0.7.16
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 -2
- package/package.json +1 -1
- package/worker/src/approval-oidc.ts +38 -9
- package/worker/src/index.ts +93 -35
- package/worker/test/approval-oidc.test.ts +40 -1
- package/worker/test/hosted.test.ts +11 -0
- package/worker/test/mcp.test.ts +12 -0
- package/worker/test/pairing.test.ts +65 -0
package/lib/main.mjs
CHANGED
|
@@ -191,8 +191,8 @@ Control plane account (only when that deployment's login is on)
|
|
|
191
191
|
page. Creating an agent shows the owner token once. The agent page rotates
|
|
192
192
|
that token after a confirmation and a recent sign-in. The new token is
|
|
193
193
|
shown once on that agent's page. The previous one stops working immediately.
|
|
194
|
-
The token is shown only when the stored hash is still that token.
|
|
195
|
-
Deleting an agent asks for its name and a recent sign-in. A self-hosted inbox has no such delete.
|
|
194
|
+
The token is shown only when the stored hash is still that token. A Copy button sits next to it. Without JavaScript the value still copies.
|
|
195
|
+
Deleting an agent asks for its name and a recent sign-in. The account page says how many slots remain. A self-hosted inbox has no such delete.
|
|
196
196
|
That page is not stored.
|
|
197
197
|
FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
|
|
198
198
|
Unset, there is no limit. The database rejects another insert.
|
package/package.json
CHANGED
|
@@ -342,6 +342,37 @@ async function verifyIdToken(token: string, jwks: { keys?: any[] }, expect: { is
|
|
|
342
342
|
return { subject: typeof claims.sub === "string" ? claims.sub : String(claims.email) };
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
type OidcCallback = { kind: OidcKind; state: string; code: string | null; error: string | null };
|
|
346
|
+
type OpenedOidc = { row: any; userCode: string | null } | (FinishResult & { ok: false });
|
|
347
|
+
|
|
348
|
+
/** Local callback checks. `consume` deletes the sign-in row; a probe leaves it for the real GET. */
|
|
349
|
+
async function openOidc(ctx: TenantContext, o: OidcCallback, consume: boolean): Promise<OpenedOidc> {
|
|
350
|
+
const state = o.state || "";
|
|
351
|
+
if (!/^[A-Za-z0-9_-]{20,128}$/.test(state)) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
|
|
352
|
+
const row: any = consume
|
|
353
|
+
? await ctx.DB.prepare("DELETE FROM oidc_txns WHERE state = ? RETURNING *").bind(state).first()
|
|
354
|
+
: await ctx.DB.prepare("SELECT * FROM oidc_txns WHERE state = ?").bind(state).first();
|
|
355
|
+
if (!row || row.kind !== o.kind) return { ok: false, error: "This sign-in expired or was already used. Nothing was approved.", reason: "state", userCode: null };
|
|
356
|
+
const userCode = row.user_code || null;
|
|
357
|
+
if (Date.now() >= row.expires_ms) return { ok: false, error: "This sign-in expired. Nothing was approved.", reason: "expired", userCode };
|
|
358
|
+
if (o.error || !o.code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
|
|
359
|
+
if (!approvalPolicy(ctx.gates.approvalOidc).oidc || !ctx.approvalClientSecret())
|
|
360
|
+
return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
|
|
361
|
+
return { row, userCode };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export type ReadOidcResult = (FinishResult & { ok: false }) | { ok: true; pending: true; userCode: string | null };
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* What a callback can know without spending it. The sign-in row stays, and the identity provider is not called,
|
|
368
|
+
* so a HEAD probe cannot approve or burn the one-time state.
|
|
369
|
+
*/
|
|
370
|
+
export async function readOidc(ctx: TenantContext, o: OidcCallback): Promise<ReadOidcResult> {
|
|
371
|
+
const opened = await openOidc(ctx, o, false);
|
|
372
|
+
if ("ok" in opened) return opened;
|
|
373
|
+
return { ok: true, pending: true, userCode: opened.userCode };
|
|
374
|
+
}
|
|
375
|
+
|
|
345
376
|
/**
|
|
346
377
|
* Complete a sign-in. The state row is deleted on every callback, including failure, so it cannot be replayed.
|
|
347
378
|
* The CSRF cookie is not read here: SameSite=Strict is dropped on the cross-site return from the identity
|
|
@@ -350,23 +381,21 @@ async function verifyIdToken(token: string, jwks: { keys?: any[] }, expect: { is
|
|
|
350
381
|
export async function finishOidc(ctx: TenantContext, o: {
|
|
351
382
|
kind: OidcKind; redirectUri: string; state: string; code: string | null; error: string | null;
|
|
352
383
|
}): Promise<FinishResult> {
|
|
353
|
-
const
|
|
354
|
-
if (
|
|
355
|
-
const row
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (Date.now() >= row.expires_ms) return { ok: false, error: "This sign-in expired. Nothing was approved.", reason: "expired", userCode };
|
|
359
|
-
if (o.error || !o.code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
|
|
384
|
+
const opened = await openOidc(ctx, o, true);
|
|
385
|
+
if ("ok" in opened) return opened;
|
|
386
|
+
const { row, userCode } = opened;
|
|
387
|
+
const code = o.code;
|
|
388
|
+
if (!code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
|
|
360
389
|
const gate = ctx.gates.approvalOidc;
|
|
361
390
|
const secret = ctx.approvalClientSecret();
|
|
362
|
-
if (!
|
|
391
|
+
if (!secret) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
|
|
363
392
|
let disc: Discovery;
|
|
364
393
|
try { disc = await discover(ctx, gate.issuer); }
|
|
365
394
|
catch { return { ok: false, error: UNREACHABLE, reason: "discovery", userCode }; }
|
|
366
395
|
let idToken = "";
|
|
367
396
|
try {
|
|
368
397
|
const body = new URLSearchParams({
|
|
369
|
-
grant_type: "authorization_code", code
|
|
398
|
+
grant_type: "authorization_code", code, redirect_uri: o.redirectUri,
|
|
370
399
|
client_id: gate.clientId, client_secret: secret, code_verifier: row.verifier,
|
|
371
400
|
});
|
|
372
401
|
const res = await idpFetch(ctx, disc.token_endpoint, {
|
package/worker/src/index.ts
CHANGED
|
@@ -911,7 +911,7 @@ function ownerTaskView(t: Json): Json {
|
|
|
911
911
|
};
|
|
912
912
|
}
|
|
913
913
|
|
|
914
|
-
async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false): Promise<Response> {
|
|
914
|
+
async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionContext, path: string, url: URL, trusted = false, head = false): Promise<Response> {
|
|
915
915
|
// trusted: an in-process call. MCP tools use ownerCall (paths fixed in the tool code). The hosted
|
|
916
916
|
// owner() RPC uses runOwnerRpc, which has its own allowlist. A request from the network is never trusted.
|
|
917
917
|
if (!trusted && !(await isOwner(ctx, req.headers.get("authorization")))) return json({ error: "unauthorized" }, 401);
|
|
@@ -949,13 +949,16 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
949
949
|
const lastUser = [...t.history].reverse().find((x: Json) => x.role === "user") || {};
|
|
950
950
|
out.push({ taskId: t.id, contextId: t.contextId, from: t._peer, state: t.status.state, updated: t.status.timestamp, text: A.textOf(lastUser) });
|
|
951
951
|
}
|
|
952
|
-
// the owner has now seen these tasks: drop pending wakes that only cover them
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
952
|
+
// the owner has now seen these tasks: drop pending wakes that only cover them.
|
|
953
|
+
// HEAD returns the same status and headers and does not mark the inbox seen.
|
|
954
|
+
if (!head) {
|
|
955
|
+
const seen = new Set(out.map((o) => o.taskId));
|
|
956
|
+
const pend = await ctx.DB.prepare("SELECT context_id, pending_json FROM wakes WHERE pending_json IS NOT NULL").all();
|
|
957
|
+
for (const p of pend.results as Json[]) {
|
|
958
|
+
const ids: string[] = JSON.parse(p.pending_json).taskIds || [];
|
|
959
|
+
if (ids.length && ids.every((i) => seen.has(i)))
|
|
960
|
+
await ctx.DB.prepare("UPDATE wakes SET pending_json = NULL, pending_since_ms = NULL WHERE context_id = ? AND pending_json = ?").bind(p.context_id, p.pending_json).run();
|
|
961
|
+
}
|
|
959
962
|
}
|
|
960
963
|
return json(out);
|
|
961
964
|
}
|
|
@@ -1206,6 +1209,15 @@ const pairingCleanup = (ctx: TenantContext, now: number) => [
|
|
|
1206
1209
|
ctx.DB.prepare("DELETE FROM oidc_txns WHERE expires_ms < ?").bind(now),
|
|
1207
1210
|
];
|
|
1208
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* True when this call is past `limit`. HEAD peeks: the counter is not incremented, and a count already at the cap
|
|
1214
|
+
* is past it, which is the decision one more increment would make.
|
|
1215
|
+
*/
|
|
1216
|
+
async function overLimit(ctx: TenantContext, key: string, windowS: number, limit: number, head: boolean): Promise<boolean> {
|
|
1217
|
+
const n = await bump(ctx, key, windowS, head);
|
|
1218
|
+
return head ? n >= limit : n > limit;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1209
1221
|
/** Fixed-window counter: increments and returns the count for `key` in the current window (peek: no increment). */
|
|
1210
1222
|
async function bump(ctx: TenantContext, key: string, windowS: number, peek = false): Promise<number> {
|
|
1211
1223
|
const now = Date.now(), start = Math.floor(now / (windowS * 1000));
|
|
@@ -1402,7 +1414,7 @@ function cookie(req: Request, name: string): string {
|
|
|
1402
1414
|
}
|
|
1403
1415
|
|
|
1404
1416
|
/** GET/POST /device: the owner approves or denies a request with the approval password (no scripts, strict CSP). */
|
|
1405
|
-
async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1417
|
+
async function devicePage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
1406
1418
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1407
1419
|
const csrfName = "a2a_device_csrf";
|
|
1408
1420
|
let csrf = cookie(req, csrfName);
|
|
@@ -1417,7 +1429,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
|
|
|
1417
1429
|
};
|
|
1418
1430
|
const show = async (raw: string | null, notice?: P.PageModel["notice"]) => {
|
|
1419
1431
|
model.notice = notice;
|
|
1420
|
-
if (raw &&
|
|
1432
|
+
if (raw && await overLimit(ctx, `look:ip:${ip || "?"}`, P.LIMITS.lookupsWindowS, P.LIMITS.lookupsPerIp, head)) {
|
|
1421
1433
|
model.askCode = true;
|
|
1422
1434
|
model.notice = { kind: "error", text: "Too many code lookups from this address. Try again in a few minutes." };
|
|
1423
1435
|
return;
|
|
@@ -1511,7 +1523,7 @@ async function devicePage(req: Request, ctx: TenantContext, url: URL): Promise<R
|
|
|
1511
1523
|
}
|
|
1512
1524
|
|
|
1513
1525
|
/** GET /device/oidc/callback: the identity provider returns here. State is the CSRF; the cookie is not re-checked. */
|
|
1514
|
-
async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1526
|
+
async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
1515
1527
|
const nonce = P.randomB64();
|
|
1516
1528
|
const pw = await approvalPassword(ctx);
|
|
1517
1529
|
const model: P.PageModel = { agentName: ctx.AGENT_NAME || "this A2A inbox", mode: pairingMode(ctx), cli: cliCommand(ctx), csrf: P.randomB64(), passwordSet: !!pw, ...approvalPageBits(ctx) };
|
|
@@ -1520,10 +1532,31 @@ async function deviceOidcCallback(req: Request, ctx: TenantContext, url: URL): P
|
|
|
1520
1532
|
h["set-cookie"] = `a2a_device_csrf=${model.csrf}; Path=/device; Secure; HttpOnly; SameSite=Strict; Max-Age=3600`;
|
|
1521
1533
|
return new Response(P.devicePage(model, nonce), { status, headers: h });
|
|
1522
1534
|
};
|
|
1523
|
-
const
|
|
1524
|
-
kind: "device", redirectUri: `${oauthUrls(ctx).page}/oidc/callback`,
|
|
1535
|
+
const callback = {
|
|
1536
|
+
kind: "device" as const, redirectUri: `${oauthUrls(ctx).page}/oidc/callback`,
|
|
1525
1537
|
state: url.searchParams.get("state") || "", code: url.searchParams.get("code"), error: url.searchParams.get("error"),
|
|
1526
|
-
}
|
|
1538
|
+
};
|
|
1539
|
+
// HEAD must not delete the sign-in or approve. A still-open sign-in answers with the device page (200, the status a
|
|
1540
|
+
// completed approval uses) and does not say approved. The browser GET still finishes it.
|
|
1541
|
+
if (head) {
|
|
1542
|
+
const seen = await O.readOidc(ctx, callback);
|
|
1543
|
+
if (!seen.ok) {
|
|
1544
|
+
if (seen.userCode) {
|
|
1545
|
+
const pending = await pendingRequest(ctx, seen.userCode);
|
|
1546
|
+
if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) model.request = pageRequest(pending);
|
|
1547
|
+
}
|
|
1548
|
+
model.notice = { kind: "error", text: seen.error };
|
|
1549
|
+
return send(400);
|
|
1550
|
+
}
|
|
1551
|
+
const pending = seen.userCode ? await pendingRequest(ctx, seen.userCode) : null;
|
|
1552
|
+
if (pending && pending.status === "pending" && Date.now() < pending.expires_ms) {
|
|
1553
|
+
model.request = pageRequest(pending);
|
|
1554
|
+
return send(200);
|
|
1555
|
+
}
|
|
1556
|
+
model.notice = { kind: "error", text: "Nothing was approved." };
|
|
1557
|
+
return send(409);
|
|
1558
|
+
}
|
|
1559
|
+
const done = await O.finishOidc(ctx, callback);
|
|
1527
1560
|
if (!done.ok) {
|
|
1528
1561
|
log("oidc_rejected", { kind: "device", reason: done.reason });
|
|
1529
1562
|
if (done.userCode) {
|
|
@@ -1559,7 +1592,7 @@ async function liveSetup(ctx: TenantContext, token: string): Promise<{ rec: Setu
|
|
|
1559
1592
|
}
|
|
1560
1593
|
|
|
1561
1594
|
/** 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'. */
|
|
1562
|
-
async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1595
|
+
async function setupPage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
1563
1596
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1564
1597
|
const csrfName = "a2a_device_csrf";
|
|
1565
1598
|
let csrf = cookie(req, csrfName);
|
|
@@ -1577,7 +1610,7 @@ async function setupPage(req: Request, ctx: TenantContext, url: URL): Promise<Re
|
|
|
1577
1610
|
model.notice = { kind: "error", text };
|
|
1578
1611
|
return send(status);
|
|
1579
1612
|
};
|
|
1580
|
-
if (
|
|
1613
|
+
if (await overLimit(ctx, `setup:ip:${ip || "?"}`, P.LIMITS.setupWindowS, P.LIMITS.setupPerIp, head)) {
|
|
1581
1614
|
log("password_setup_rate_limited", { ip });
|
|
1582
1615
|
return invalid(429, "Too many attempts from this address. Try again in a few minutes.");
|
|
1583
1616
|
}
|
|
@@ -1871,7 +1904,7 @@ async function pinnedGet(ip: string, url: string, max: number, signal: AbortSign
|
|
|
1871
1904
|
* Document (fetched with SSRF guards: public https host other than this Worker whose A/AAAA records are all public
|
|
1872
1905
|
* (DNS-over-HTTPS), connected to at that checked IP (no DNS rebinding), no redirects, no credentials, 5 s, 5 KB; cached for its
|
|
1873
1906
|
* max-age; failures and invalid documents are not cached). `ip` rate-limits fetches (not cached lookups). */
|
|
1874
|
-
async function resolveClient(ctx: TenantContext, id: string, ip?: string): Promise<{ client?: Json; error?: string }> {
|
|
1907
|
+
async function resolveClient(ctx: TenantContext, id: string, ip?: string, head = false): Promise<{ client?: Json; error?: string }> {
|
|
1875
1908
|
if (!id) return { error: "client_id is required" };
|
|
1876
1909
|
if (!/^https:\/\//i.test(id)) {
|
|
1877
1910
|
const c: Json = await ctx.DB.prepare("SELECT * FROM oauth_clients WHERE client_id = ?").bind(id).first();
|
|
@@ -1882,6 +1915,12 @@ async function resolveClient(ctx: TenantContext, id: string, ip?: string): Promi
|
|
|
1882
1915
|
const now = Date.now();
|
|
1883
1916
|
const hit: Json = await ctx.DB.prepare("SELECT * FROM oauth_client_metadata WHERE client_id = ? AND expires_ms > ?").bind(id, now).first();
|
|
1884
1917
|
if (hit) return { client: { ...hit, cimd: true } };
|
|
1918
|
+
// HEAD does not fetch the document or spend the lookup limit. A cache miss is the same refusal as a failed fetch.
|
|
1919
|
+
if (head) {
|
|
1920
|
+
if (ip !== undefined && await overLimit(ctx, `cimd:ip:${ip || "?"}`, 3600, M.CIMD.fetchesPerIpPerHour, true))
|
|
1921
|
+
return { error: "Too many client metadata lookups from this address. Try again later." };
|
|
1922
|
+
return { error: "The client's metadata document could not be fetched." };
|
|
1923
|
+
}
|
|
1885
1924
|
if (ip !== undefined && (await bump(ctx, `cimd:ip:${ip || "?"}`, 3600)) > M.CIMD.fetchesPerIpPerHour)
|
|
1886
1925
|
return { error: "Too many client metadata lookups from this address. Try again later." };
|
|
1887
1926
|
const signal = AbortSignal.timeout(M.CIMD.timeoutMs);
|
|
@@ -1920,11 +1959,11 @@ async function resolveClient(ctx: TenantContext, id: string, ip?: string): Promi
|
|
|
1920
1959
|
type AuthzCheck = { fatal?: string; error?: string; description?: string; client?: Json; redirect?: string; params: Record<string, string> };
|
|
1921
1960
|
|
|
1922
1961
|
/** Validate an authorization request. fatal: show an error page (no trustworthy redirect); error: redirect back with it. */
|
|
1923
|
-
async function checkAuthorize(ctx: TenantContext, q: Record<string, string>, ip: string): Promise<AuthzCheck> {
|
|
1962
|
+
async function checkAuthorize(ctx: TenantContext, q: Record<string, string>, ip: string, head = false): Promise<AuthzCheck> {
|
|
1924
1963
|
const params: Record<string, string> = {};
|
|
1925
1964
|
for (const k of ["response_type", "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method", "scope", "resource"])
|
|
1926
1965
|
if (typeof q[k] === "string" && q[k] !== "") params[k] = q[k].slice(0, 2048);
|
|
1927
|
-
const rc = await resolveClient(ctx, params.client_id || "", ip);
|
|
1966
|
+
const rc = await resolveClient(ctx, params.client_id || "", ip, head);
|
|
1928
1967
|
if (!rc.client) return { fatal: rc.error!, params };
|
|
1929
1968
|
const client = rc.client;
|
|
1930
1969
|
const uris: string[] = JSON.parse(client.redirect_uris_json || "[]");
|
|
@@ -1948,7 +1987,7 @@ function redirectWith(ctx: TenantContext, redirect: string, q: Record<string, st
|
|
|
1948
1987
|
}
|
|
1949
1988
|
|
|
1950
1989
|
/** GET/POST /oauth/authorize: the owner approves an MCP client with the approval password (same lockout as /device). */
|
|
1951
|
-
async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
1990
|
+
async function authorizePage(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
1952
1991
|
const nonce = P.randomB64(), ip = clientIp(req);
|
|
1953
1992
|
const csrfName = "a2a_mcp_csrf";
|
|
1954
1993
|
let csrf = cookie(req, csrfName);
|
|
@@ -1959,7 +1998,7 @@ async function authorizePage(req: Request, ctx: TenantContext, url: URL): Promis
|
|
|
1959
1998
|
try { form = P.parseParams(await readBody(req, { ...ctx, MAX_BODY: "16384" }), "application/x-www-form-urlencoded"); } catch { /* empty */ }
|
|
1960
1999
|
}
|
|
1961
2000
|
const q = req.method === "GET" ? Object.fromEntries(url.searchParams) : form;
|
|
1962
|
-
const c = await checkAuthorize(ctx, q, ip);
|
|
2001
|
+
const c = await checkAuthorize(ctx, q, ip, head);
|
|
1963
2002
|
const pw = await approvalPassword(ctx);
|
|
1964
2003
|
const page = (status: number, body: string, formOrigins: string[] = []) => {
|
|
1965
2004
|
// Approve and deny 302 to the client. OpenID Connect 302s to the issuer. Chrome checks both against form-action.
|
|
@@ -2030,13 +2069,20 @@ async function issueMcpCode(ctx: TenantContext, clientName: string, redirect: st
|
|
|
2030
2069
|
* 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.
|
|
2031
2070
|
* The error page has no form.
|
|
2032
2071
|
*/
|
|
2033
|
-
async function mcpOidcCallback(req: Request, ctx: TenantContext, url: URL): Promise<Response> {
|
|
2072
|
+
async function mcpOidcCallback(req: Request, ctx: TenantContext, url: URL, head = false): Promise<Response> {
|
|
2034
2073
|
const nonce = P.randomB64();
|
|
2035
2074
|
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) });
|
|
2036
|
-
const
|
|
2037
|
-
kind: "mcp", redirectUri: `${oauthUrls(ctx).authorize}/oidc/callback`,
|
|
2075
|
+
const callback = {
|
|
2076
|
+
kind: "mcp" as const, redirectUri: `${oauthUrls(ctx).authorize}/oidc/callback`,
|
|
2038
2077
|
state: url.searchParams.get("state") || "", code: url.searchParams.get("code"), error: url.searchParams.get("error"),
|
|
2039
|
-
}
|
|
2078
|
+
};
|
|
2079
|
+
// The successful GET is a 302 that carries a newly issued code. HEAD does not issue that code or delete the sign-in,
|
|
2080
|
+
// so it stays on the not-approved page. The browser GET still completes the redirect.
|
|
2081
|
+
if (head) {
|
|
2082
|
+
const seen = await O.readOidc(ctx, callback);
|
|
2083
|
+
return fail(400, seen.ok ? "Nothing was approved." : seen.error);
|
|
2084
|
+
}
|
|
2085
|
+
const done = await O.finishOidc(ctx, callback);
|
|
2040
2086
|
if (!done.ok) {
|
|
2041
2087
|
log("oidc_rejected", { kind: "mcp", reason: done.reason });
|
|
2042
2088
|
return fail(400, done.error);
|
|
@@ -2462,26 +2508,38 @@ async function handleMcp(req: Request, ctx: TenantContext, ectx: ExecutionContex
|
|
|
2462
2508
|
return rpcError(id, M.ERR.METHOD_NOT_FOUND, `method not found: ${msg.method}`);
|
|
2463
2509
|
}
|
|
2464
2510
|
|
|
2465
|
-
|
|
2511
|
+
/** Hosted cards disappear when the agent is deleted. There is no purge binding, so the public max-age is short. Self-host has no such delete. */
|
|
2512
|
+
function cardCacheControl(ctx: TenantContext): string {
|
|
2513
|
+
return ctx.mode === "host" ? "public, max-age=60" : "public, max-age=300";
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
/** Same status and headers as the GET response, with an empty body. The body is discarded, not copied. */
|
|
2517
|
+
async function emptyHead(res: Response): Promise<Response> {
|
|
2518
|
+
try { await res.body?.cancel(); } catch { /* the status and headers still answer the HEAD */ }
|
|
2519
|
+
return new Response(null, { status: res.status, headers: res.headers });
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext, head = false): Promise<Response> {
|
|
2523
|
+
if (req.method === "HEAD") return emptyHead(await handle(new Request(req.url, { method: "GET", headers: req.headers }), ctx, ectx, true));
|
|
2466
2524
|
const url = new URL(req.url);
|
|
2467
2525
|
const path = url.pathname;
|
|
2468
2526
|
try {
|
|
2469
2527
|
if (req.method === "GET" && path === "/.well-known/agent-card.json")
|
|
2470
|
-
return json(await agentCard(ctx), 200, { "access-control-allow-origin": "*", "cache-control":
|
|
2528
|
+
return json(await agentCard(ctx), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
|
|
2471
2529
|
// legacy discovery path (A2A 0.2): the 0.3-shaped card that clients of that era parse
|
|
2472
2530
|
if (req.method === "GET" && path === "/.well-known/agent.json")
|
|
2473
|
-
return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control":
|
|
2531
|
+
return json(agentCard03(ctx, await agentCard(ctx)), 200, { "access-control-allow-origin": "*", "cache-control": cardCacheControl(ctx) });
|
|
2474
2532
|
if (req.method === "GET" && path === "/health") return json({ ok: true, publicUrl: ctx.PUBLIC_URL, mode: proxyMode(ctx) ? "proxy" : "inbox", mcp: mcpOn(ctx) });
|
|
2475
2533
|
if (path === "/mcp") return mcpOn(ctx) ? await handleMcp(req, ctx, ectx, url) : json({ error: "not_found", error_description: "the MCP server is turned off on this inbox (MCP=off)" }, 404);
|
|
2476
2534
|
if (mcpOn(ctx)) {
|
|
2477
2535
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource/mcp") return json(mcpResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2478
2536
|
if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") return json(oauthMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2479
2537
|
if (req.method === "POST" && path === "/oauth/register") return await registerClient(req, ctx);
|
|
2480
|
-
if (req.method === "GET" && path === "/oauth/authorize/oidc/callback") return await mcpOidcCallback(req, ctx, url);
|
|
2481
|
-
if ((req.method === "GET" || req.method === "POST") && path === "/oauth/authorize") return await authorizePage(req, ctx, url);
|
|
2538
|
+
if (req.method === "GET" && path === "/oauth/authorize/oidc/callback") return await mcpOidcCallback(req, ctx, url, head);
|
|
2539
|
+
if ((req.method === "GET" || req.method === "POST") && path === "/oauth/authorize") return await authorizePage(req, ctx, url, head);
|
|
2482
2540
|
if (req.method === "POST" && path === "/oauth/revoke") return await revokeEndpoint(req, ctx);
|
|
2483
2541
|
if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
|
|
2484
|
-
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url);
|
|
2542
|
+
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
|
|
2485
2543
|
}
|
|
2486
2544
|
if (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/oauth-protected-resource" || path.startsWith("/oauth/") || path === "/device" || path === "/device/setup" || path === "/device/oidc/callback") {
|
|
2487
2545
|
if (!pairingOn(ctx)) return json({ error: "not_found", error_description: "device-flow pairing is disabled on this inbox (PAIRING_APPROVAL=off); ask its operator for a token" }, 404);
|
|
@@ -2489,14 +2547,14 @@ async function handle(req: Request, ctx: TenantContext, ectx: ExecutionContext):
|
|
|
2489
2547
|
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") return json(protectedResourceMetadata(ctx), 200, { "access-control-allow-origin": "*" });
|
|
2490
2548
|
if (req.method === "POST" && path === "/oauth/device_authorization") return await deviceAuthorization(req, ctx, ectx);
|
|
2491
2549
|
if (req.method === "POST" && path === "/oauth/token") return await tokenEndpoint(req, ctx);
|
|
2492
|
-
if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url);
|
|
2493
|
-
if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, url);
|
|
2494
|
-
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url);
|
|
2550
|
+
if (path === "/device" && (req.method === "GET" || req.method === "POST")) return await devicePage(req, ctx, url, head);
|
|
2551
|
+
if (req.method === "GET" && path === "/device/oidc/callback") return await deviceOidcCallback(req, ctx, url, head);
|
|
2552
|
+
if (path === "/device/setup" && (req.method === "GET" || req.method === "POST")) return await setupPage(req, ctx, url, head);
|
|
2495
2553
|
return json({ error: "not found" }, 404);
|
|
2496
2554
|
}
|
|
2497
2555
|
if (req.method === "POST" && (path === "/" || path === "/a2a" || path === "/a2a/v1")) return await handleRpc(req, ctx, ectx);
|
|
2498
2556
|
if (req.method === "POST" && path === "/push") return await handlePush(req, ctx, ectx);
|
|
2499
|
-
if (path === "/owner" || path.startsWith("/owner/")) return await handleOwner(req, ctx, ectx, path, url);
|
|
2557
|
+
if (path === "/owner" || path.startsWith("/owner/")) return await handleOwner(req, ctx, ectx, path, url, false, head);
|
|
2500
2558
|
if ((req.method === "GET" || req.method === "HEAD") && path === "/") return await landing(req, ctx, url);
|
|
2501
2559
|
return json({ error: "not found" }, 404);
|
|
2502
2560
|
} catch (e: any) {
|
|
@@ -369,6 +369,37 @@ test("email allowlist matches and a hosted tenant uses its own approval, not the
|
|
|
369
369
|
assert.equal(s.tokenBodies.at(-1)!.client_secret, "tenant-secret");
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
+
test("HEAD on an OpenID Connect callback leaves the sign-in unused", async (t) => {
|
|
373
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
374
|
+
const d = await s.start();
|
|
375
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
376
|
+
assert.equal(post.status, 302, post.text);
|
|
377
|
+
const loc = new URL(post.headers.get("location")!);
|
|
378
|
+
const state = loc.searchParams.get("state")!;
|
|
379
|
+
s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
|
|
380
|
+
const head = await s.call("HEAD", `/device/oidc/callback?code=auth-code&state=${state}`);
|
|
381
|
+
assert.equal(head.status, 200);
|
|
382
|
+
assert.equal(head.text, "");
|
|
383
|
+
assert.match(head.headers.get("content-type") || "", /^text\/html/);
|
|
384
|
+
assert.equal(s.tokenBodies.length, 0);
|
|
385
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state)!.n, 1);
|
|
386
|
+
const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${state}`);
|
|
387
|
+
assert.equal(back.status, 200, back.text);
|
|
388
|
+
assert.match(back.text, /Approved/);
|
|
389
|
+
assert.equal(s.tokenBodies.length, 1);
|
|
390
|
+
|
|
391
|
+
const again = await s.start();
|
|
392
|
+
const started = await s.postOidc(again.data.user_code);
|
|
393
|
+
const state2 = new URL(started.post.headers.get("location")!).searchParams.get("state")!;
|
|
394
|
+
const denied = await s.call("HEAD", `/device/oidc/callback?error=access_denied&state=${state2}`);
|
|
395
|
+
assert.equal(denied.status, 400);
|
|
396
|
+
assert.equal(denied.text, "");
|
|
397
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state2)!.n, 1);
|
|
398
|
+
const still = await s.call("GET", `/device/oidc/callback?error=access_denied&state=${state2}`);
|
|
399
|
+
assert.equal(still.status, 400);
|
|
400
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state2)!.n, 0);
|
|
401
|
+
});
|
|
402
|
+
|
|
372
403
|
test("MCP consent offers Continue with the issuer and the callback issues the authorization code", async (t) => {
|
|
373
404
|
const s = setup(oidcEnv); t.after(s.restore);
|
|
374
405
|
const reg = await s.call("POST", "/oauth/register", { json: { client_name: "Claude", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] } });
|
|
@@ -386,7 +417,15 @@ test("MCP consent offers Continue with the issuer and the callback issues the au
|
|
|
386
417
|
const loc = new URL(post.headers.get("location")!);
|
|
387
418
|
assert.equal(loc.search.includes("st-1"), false, "the MCP state is not the identity provider's state");
|
|
388
419
|
s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
|
|
389
|
-
const
|
|
420
|
+
const state = loc.searchParams.get("state")!;
|
|
421
|
+
const head = await s.call("HEAD", `/oauth/authorize/oidc/callback?code=auth-code&state=${state}`);
|
|
422
|
+
assert.equal(head.status, 400);
|
|
423
|
+
assert.equal(head.text, "");
|
|
424
|
+
assert.equal(head.headers.get("location"), null);
|
|
425
|
+
assert.equal(s.tokenBodies.length, 0);
|
|
426
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oidc_txns WHERE state = ?").get(state)!.n, 1);
|
|
427
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oauth_codes").get()!.n, 0);
|
|
428
|
+
const back = await s.call("GET", `/oauth/authorize/oidc/callback?code=auth-code&state=${state}`);
|
|
390
429
|
assert.equal(back.status, 302, back.text);
|
|
391
430
|
const callbackError = await s.call("GET", "/oauth/authorize/oidc/callback?error=access_denied");
|
|
392
431
|
assert.equal(callbackError.status, 400);
|
|
@@ -181,6 +181,17 @@ test("per-tenant SQLite: directory, config push, and isolation", async (t) => {
|
|
|
181
181
|
await kv.put("tenant:bobby", JSON.stringify({ id: "id-bobby", status: "active", region: "", version: 1 }));
|
|
182
182
|
|
|
183
183
|
const card = async (name: string, headers?: HeadersInit) => (await mf.dispatchFetch(`https://${name}.example.com/.well-known/agent-card.json`, { headers })).json() as Promise<{ name: string }>;
|
|
184
|
+
const aliceCard = await mf.dispatchFetch("https://alice.example.com/.well-known/agent-card.json");
|
|
185
|
+
assert.equal(aliceCard.headers.get("cache-control"), "public, max-age=60");
|
|
186
|
+
assert.equal((await aliceCard.json() as { name: string }).name, "Alice");
|
|
187
|
+
const aliceHead = await mf.dispatchFetch("https://alice.example.com/.well-known/agent-card.json", { method: "HEAD" });
|
|
188
|
+
assert.equal(aliceHead.status, 200);
|
|
189
|
+
assert.equal(await aliceHead.text(), "");
|
|
190
|
+
assert.equal(aliceHead.headers.get("content-type"), "application/json");
|
|
191
|
+
assert.equal(aliceHead.headers.get("cache-control"), "public, max-age=60");
|
|
192
|
+
const aliceLegacy = await mf.dispatchFetch("https://alice.example.com/.well-known/agent.json", { method: "HEAD" });
|
|
193
|
+
assert.equal(aliceLegacy.status, 200);
|
|
194
|
+
assert.equal(await aliceLegacy.text(), "");
|
|
184
195
|
assert.equal((await card("alice")).name, "Alice");
|
|
185
196
|
assert.equal((await card("bobby")).name, "Bob");
|
|
186
197
|
assert.equal((await card("alice", { "x-a2a-tenant": JSON.stringify({ name: "bob", config: { AGENT_NAME: "Bob" } }) })).name, "Alice");
|
package/worker/test/mcp.test.ts
CHANGED
|
@@ -578,11 +578,23 @@ test("metadata advertises CIMD and RFC 9207 iss; DCR accepts application_type; n
|
|
|
578
578
|
test("Client ID Metadata Documents: fetched, validated, cached; consent shows the client_id host; tokens and refresh work", async (t) => {
|
|
579
579
|
const s = setup(); t.after(s.restore);
|
|
580
580
|
s.cimd["/oauth/metadata.json"] = { body: cimdDoc(), headers: { "cache-control": "max-age=600" } };
|
|
581
|
+
const challenge = await M.s256(b64url(crypto.getRandomValues(new Uint8Array(32))));
|
|
582
|
+
const probe = new URLSearchParams({ response_type: "code", client_id: CIMD_ID, redirect_uri: CLAUDE_CB, state: "s1", code_challenge: challenge, code_challenge_method: "S256" });
|
|
583
|
+
const head = await s.call("HEAD", `/oauth/authorize?${probe}`);
|
|
584
|
+
assert.equal(head.status, 400);
|
|
585
|
+
assert.equal(head.text, "");
|
|
586
|
+
assert.equal(s.cimdFetches.length, 0, "HEAD does not fetch client metadata");
|
|
587
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM oauth_client_metadata").get()!.n, 0);
|
|
588
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM pairing_rate WHERE key LIKE 'cimd:%'").get()!.n, 0);
|
|
581
589
|
const r = await authorizeWith(s, CIMD_ID, { resource: `${BASE}/mcp` });
|
|
582
590
|
assert.equal(r.page.status, 200, r.page.text);
|
|
583
591
|
assert.match(r.page.text, /CIMD Client/);
|
|
584
592
|
assert.match(r.page.text, /Client identity published at<\/th><td>client\.example\.org/);
|
|
585
593
|
assert.equal(s.cimdFetches.length, 1);
|
|
594
|
+
const cached = await s.call("HEAD", `/oauth/authorize?${new URLSearchParams(r.q)}`);
|
|
595
|
+
assert.equal(cached.status, 200);
|
|
596
|
+
assert.equal(cached.text, "");
|
|
597
|
+
assert.equal(s.cimdFetches.length, 1, "a cached document is not fetched again for HEAD");
|
|
586
598
|
assert.equal(s.cimdFetches[0].ip, "93.184.216.34", "connected to the DNS-checked address (pinned: no rebinding)");
|
|
587
599
|
assert.equal(s.cimdFetches[0].sni, "client.example.org", "TLS verified for the client_id host");
|
|
588
600
|
assert.equal(s.cimdFetches[0].port, 443);
|
|
@@ -62,6 +62,65 @@ function setup(over: Record<string, string> = {}) {
|
|
|
62
62
|
return { env, DB, wakes, call, owner, start, poll, decidePage, restore: () => { globalThis.fetch = origFetch; } };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
test("HEAD answers like GET with an empty body, and a self-hosted card stays cached for 5 minutes", async (t) => {
|
|
66
|
+
const s = setup();
|
|
67
|
+
t.after(s.restore);
|
|
68
|
+
const get = await s.call("GET", "/.well-known/agent-card.json");
|
|
69
|
+
assert.equal(get.status, 200);
|
|
70
|
+
assert.equal(get.headers.get("cache-control"), "public, max-age=300");
|
|
71
|
+
const head = await s.call("HEAD", "/.well-known/agent-card.json");
|
|
72
|
+
assert.equal(head.status, 200);
|
|
73
|
+
assert.equal(head.text, "");
|
|
74
|
+
assert.equal(head.headers.get("cache-control"), "public, max-age=300");
|
|
75
|
+
assert.match(head.headers.get("content-type") ?? "", /^application\/json/);
|
|
76
|
+
const legacy = await s.call("HEAD", "/.well-known/agent.json");
|
|
77
|
+
assert.equal(legacy.status, 200);
|
|
78
|
+
assert.equal(legacy.text, "");
|
|
79
|
+
assert.equal(legacy.headers.get("cache-control"), "public, max-age=300");
|
|
80
|
+
const health = await s.call("HEAD", "/health");
|
|
81
|
+
assert.equal(health.status, 200);
|
|
82
|
+
assert.equal(health.text, "");
|
|
83
|
+
assert.match(health.headers.get("content-type") ?? "", /^application\/json/);
|
|
84
|
+
const missing = await s.call("HEAD", "/no-such-route");
|
|
85
|
+
assert.equal(missing.status, 404);
|
|
86
|
+
assert.equal(missing.text, "");
|
|
87
|
+
const owner = await s.call("HEAD", "/owner/peers");
|
|
88
|
+
assert.equal(owner.status, 401);
|
|
89
|
+
assert.equal(owner.text, "");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("HEAD does not spend setup or pairing lookup limits", async (t) => {
|
|
93
|
+
const s = setup();
|
|
94
|
+
t.after(s.restore);
|
|
95
|
+
const link = (await s.owner("POST", "/owner/pairing/password-link", {})).data;
|
|
96
|
+
const path = new URL(link.url).pathname + new URL(link.url).search;
|
|
97
|
+
const ip = "192.0.2.20";
|
|
98
|
+
for (let i = 0; i < P.LIMITS.setupPerIp; i++) {
|
|
99
|
+
const h = await s.call("HEAD", path, { ip });
|
|
100
|
+
assert.equal(h.status, 200, h.text);
|
|
101
|
+
assert.equal(h.text, "");
|
|
102
|
+
}
|
|
103
|
+
assert.equal(s.DB.db.prepare("SELECT COUNT(*) AS n FROM pairing_rate WHERE key = ?").get(`setup:ip:${ip}`)!.n, 0);
|
|
104
|
+
for (let i = 0; i < P.LIMITS.setupPerIp; i++) assert.equal((await s.call("GET", path, { ip })).status, 200);
|
|
105
|
+
const blocked = await s.call("HEAD", path, { ip });
|
|
106
|
+
assert.equal(blocked.status, 429);
|
|
107
|
+
assert.equal(blocked.text, "");
|
|
108
|
+
assert.equal((s.DB.db.prepare("SELECT count FROM pairing_rate WHERE key = ?").get(`setup:ip:${ip}`) as { count: number }).count, P.LIMITS.setupPerIp);
|
|
109
|
+
assert.equal((await s.call("GET", path, { ip })).status, 429);
|
|
110
|
+
|
|
111
|
+
const d = await s.start(undefined, "192.0.2.21");
|
|
112
|
+
const look = `/device?user_code=${d.data.user_code}`;
|
|
113
|
+
for (let i = 0; i < P.LIMITS.lookupsPerIp; i++) {
|
|
114
|
+
const h = await s.call("HEAD", look, { ip: "192.0.2.21" });
|
|
115
|
+
assert.equal(h.status, 200);
|
|
116
|
+
assert.equal(h.text, "");
|
|
117
|
+
}
|
|
118
|
+
const page = await s.call("GET", look, { ip: "192.0.2.21" });
|
|
119
|
+
assert.equal(page.status, 200);
|
|
120
|
+
assert.match(page.text, /Barry Bot/);
|
|
121
|
+
assert.equal(page.text.includes("Too many code lookups"), false);
|
|
122
|
+
});
|
|
123
|
+
|
|
65
124
|
test("pairing helpers: user codes, labels, requester text, params, password hashing", async () => {
|
|
66
125
|
for (let i = 0; i < 200; i++) assert.match(P.newUserCode(), /^[BCDFGHJKLMNPQRSTVWXZ]{4}[2-9]{4}$/);
|
|
67
126
|
assert.equal(P.formatUserCode("WDJB4827"), "WDJB-4827");
|
|
@@ -753,4 +812,10 @@ test("owner log lists history without data_json and does not clear a pending wak
|
|
|
753
812
|
const inbox = await s.owner("GET", "/owner/inbox");
|
|
754
813
|
assert.equal(inbox.status, 200);
|
|
755
814
|
assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, null);
|
|
815
|
+
const again = JSON.stringify({ taskIds: [taskId] });
|
|
816
|
+
s.DB.db.prepare("UPDATE wakes SET pending_json = ?, pending_since_ms = ? WHERE context_id = ?").run(again, Date.now(), "ctx-keep");
|
|
817
|
+
const head = await s.owner("HEAD", "/owner/inbox");
|
|
818
|
+
assert.equal(head.status, 200);
|
|
819
|
+
assert.equal(head.text, "");
|
|
820
|
+
assert.equal(s.DB.db.prepare("SELECT pending_json FROM wakes WHERE context_id = ?").get("ctx-keep")?.pending_json, again);
|
|
756
821
|
});
|