a2a-exposed 0.7.14 → 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 -1
- package/package.json +1 -1
- package/worker/src/approval-oidc.ts +38 -9
- package/worker/src/export.ts +2 -2
- package/worker/src/hosted.ts +27 -0
- package/worker/src/index.ts +93 -35
- package/worker/test/approval-oidc.test.ts +40 -1
- package/worker/test/export.test.ts +26 -1
- package/worker/test/hosted.test.ts +66 -0
- package/worker/test/mcp.test.ts +12 -0
- package/worker/test/pairing.test.ts +65 -0
package/lib/main.mjs
CHANGED
|
@@ -191,7 +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.
|
|
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.
|
|
195
196
|
That page is not stored.
|
|
196
197
|
FREE_AGENT_LIMIT=2 caps a free account at 2 agents. An account's own limit overrides it.
|
|
197
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/export.ts
CHANGED
|
@@ -100,7 +100,7 @@ const OMIT_COLUMNS: Record<string, readonly string[]> = {
|
|
|
100
100
|
outbound: ["push_token_hash"],
|
|
101
101
|
mcp_grants: ["access_hash", "refresh_hash"],
|
|
102
102
|
outbound_peers: ["token_enc"],
|
|
103
|
-
device_requests: ["replaces_hash"],
|
|
103
|
+
device_requests: ["replaces_hash", "ip", "country"],
|
|
104
104
|
};
|
|
105
105
|
|
|
106
106
|
const SETUP_SUB = /([?&])t=[A-Za-z0-9_-]{16,}/g;
|
|
@@ -120,7 +120,7 @@ function placeholder(table: string, row: Record<string, unknown>, index: number)
|
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
122
|
* A copy of `file` safe to show on the dashboard.
|
|
123
|
-
* Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace).
|
|
123
|
+
* Nullable credential columns are removed, including `device_requests.replaces_hash` (the hash of the token a re-pair would replace) and `device_requests.ip` and `device_requests.country` (the requester's address).
|
|
124
124
|
* `peers.token_hash` and `device_requests.device_hash` are NOT NULL, so they become `redacted:…` (not the hash, and not a token that authenticates).
|
|
125
125
|
* `oauth_codes` and `oidc_txns` are emptied. The approval-password row and the password-setup row (its value is the hash of the live setup token) are removed.
|
|
126
126
|
* Token-shaped text, including a hosted owner token (`a2aot_`), is scrubbed. The input is not changed: the CLI dump stays complete.
|
package/worker/src/hosted.ts
CHANGED
|
@@ -269,6 +269,9 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
269
269
|
async pushConfig(body: TenantConfigPush): Promise<{ version: number; applied: boolean }> {
|
|
270
270
|
return await this.ctx.blockConcurrencyWhile(async () => {
|
|
271
271
|
checkPush(body);
|
|
272
|
+
const deleted = await (this.ctx.storage as unknown as { get(key: string): Promise<unknown> }).get("tenant_deleted");
|
|
273
|
+
// deleteStorage leaves this key after wiping SQL. A later push must not recreate the agent.
|
|
274
|
+
if (deleted != null) return { version: 0, applied: false };
|
|
272
275
|
const current = this.#row ?? readRow(this.#sql);
|
|
273
276
|
if (current && body.version <= current.version) {
|
|
274
277
|
// A previous push may have stored this version and then failed while migrating. Catch up; don't rewrite the row.
|
|
@@ -322,6 +325,30 @@ export class TenantStore extends DurableObject<WorkerBindings> {
|
|
|
322
325
|
return { configured: this.#row !== null, version: this.#row?.version ?? 0, tables: appTables(this.#sql) };
|
|
323
326
|
}
|
|
324
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Drop this object's alarm and storage. A second call on an empty object succeeds.
|
|
330
|
+
* Not an owner HTTP route. Self-host never calls it.
|
|
331
|
+
*/
|
|
332
|
+
async deleteStorage(): Promise<{ deleted: true }> {
|
|
333
|
+
return await this.ctx.blockConcurrencyWhile(async () => {
|
|
334
|
+
const storage = this.ctx.storage as unknown as {
|
|
335
|
+
deleteAlarm(): Promise<void>;
|
|
336
|
+
deleteAll(): Promise<void>;
|
|
337
|
+
put(key: string, value: string): Promise<void>;
|
|
338
|
+
};
|
|
339
|
+
const tenantId = this.#row?.tenantId ?? null;
|
|
340
|
+
await storage.deleteAlarm();
|
|
341
|
+
await storage.deleteAll();
|
|
342
|
+
const left = readRow(this.#sql);
|
|
343
|
+
if (left) throw new Error("deleteStorage: data remains");
|
|
344
|
+
// After SQL is gone. A later pushConfig sees this and does not recreate the agent.
|
|
345
|
+
await storage.put("tenant_deleted", "1");
|
|
346
|
+
this.#row = null;
|
|
347
|
+
console.log(JSON.stringify({ ts: new Date().toISOString(), event: "tenant_storage_deleted", tenant: tenantId }));
|
|
348
|
+
return { deleted: true };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
325
352
|
/** SQL dump of the application tables. Refuses an object that has no config row. */
|
|
326
353
|
async exportSql(): Promise<{ sql: string; exportedAt: string }> {
|
|
327
354
|
return await this.ctx.blockConcurrencyWhile(async () => {
|
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);
|
|
@@ -88,7 +88,7 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
88
88
|
await DB.prepare("INSERT INTO settings (key, value) VALUES ('approval_password', ?)").bind(JSON.stringify({ hash: "x".repeat(40) })).run();
|
|
89
89
|
await DB.prepare("INSERT INTO settings (key, value) VALUES ('password_setup', ?)").bind(JSON.stringify({ hash: setupHash, createdMs: 1, expiresMs: 2, failures: 0 })).run();
|
|
90
90
|
await DB.prepare("INSERT INTO settings (key, value) VALUES ('theme', 'kept')").run();
|
|
91
|
-
await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada',
|
|
91
|
+
await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s, replaces_label, replaces_hash, ip, country) VALUES ('device-hash-secret', 'WDJB4827', 'pending', 1, 2, 5, 'ada', ?, '203.0.113.50', 'XQ')").bind(replaces).run();
|
|
92
92
|
await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('code-hash', 'client', 'https://client.example/cb', 'challenge-secret-value', 'mcp', 'https://agent.example/mcp', 1, 2)").run();
|
|
93
93
|
await DB.prepare("INSERT INTO mcp_grants (label, client_id, scope, access_hash, refresh_hash, created_at) VALUES ('mcp-claude', 'client', 'mcp', 'access-hash-secret', 'refresh-hash-secret', ?)").bind(now).run();
|
|
94
94
|
await DB.prepare("INSERT INTO outbound_peers (alias, url, token_enc, created_at, updated_at) VALUES ('ada', 'https://ada.example', 'ciphertext-secret-value', ?, ?)").bind(now, now).run();
|
|
@@ -97,12 +97,16 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
97
97
|
const redacted = redactExport(file);
|
|
98
98
|
assert.equal(file.tables.peers?.[0]?.token_hash, hash, "the CLI dump still has the hash");
|
|
99
99
|
assert.equal(file.tables.device_requests?.[0]?.replaces_hash, replaces);
|
|
100
|
+
assert.equal(file.tables.device_requests?.[0]?.ip, "203.0.113.50");
|
|
101
|
+
assert.equal(file.tables.device_requests?.[0]?.country, "XQ");
|
|
100
102
|
assert.equal(file.tables.settings?.some((row) => row.key === "password_setup"), true);
|
|
101
103
|
assert.equal(file.tables.oidc_txns?.[0]?.verifier, "verifier-secret-value");
|
|
102
104
|
assert.equal(redacted.tables.peers?.[0]?.token_hash, "redacted:ada");
|
|
103
105
|
assert.equal(redacted.tables.peers?.[0]?.label, "ada");
|
|
104
106
|
assert.equal(redacted.tables.device_requests?.[0]?.device_hash, "redacted:WDJB4827");
|
|
105
107
|
assert.equal(redacted.tables.device_requests?.[0]?.replaces_hash, undefined);
|
|
108
|
+
assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "ip"), false);
|
|
109
|
+
assert.equal(Object.hasOwn(redacted.tables.device_requests?.[0] ?? {}, "country"), false);
|
|
106
110
|
assert.equal(redacted.tables.device_requests?.[0]?.replaces_label, "ada");
|
|
107
111
|
assert.equal(redacted.tables.device_requests?.[0]?.user_code, "WDJB4827");
|
|
108
112
|
assert.equal(redacted.tables.tasks?.[0]?.push_configs_json, undefined);
|
|
@@ -130,6 +134,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
130
134
|
assert.equal(sql.includes("oauth_codes"), false);
|
|
131
135
|
assert.equal(sql.includes("oidc_txns"), false);
|
|
132
136
|
assert.equal(sql.includes("replaces_hash"), false);
|
|
137
|
+
assert.equal(sql.includes("203.0.113.50"), false);
|
|
138
|
+
assert.equal(sql.includes("XQ"), false);
|
|
133
139
|
assert.equal(sql.includes("password_setup"), false);
|
|
134
140
|
assert.equal(ownerResponseLeaks({ sql }), false);
|
|
135
141
|
for (const secret of [token, owner, hash, replaces, setupHash]) {
|
|
@@ -138,6 +144,8 @@ test("redactExport drops credential material and leaves the full dump unchanged"
|
|
|
138
144
|
assert.match(toSql(file), /verifier-secret-value/);
|
|
139
145
|
assert.match(toSql(file), new RegExp(hash));
|
|
140
146
|
assert.match(toSql(file), /replaces_hash/);
|
|
147
|
+
assert.match(toSql(file), /203\.0\.113\.50/);
|
|
148
|
+
assert.match(toSql(file), /'XQ'/);
|
|
141
149
|
});
|
|
142
150
|
|
|
143
151
|
test("scrubSecretText redacts a hosted owner token on the log and export paths", () => {
|
|
@@ -173,19 +181,36 @@ test("redactExport removes device_requests.replaces_hash", () => {
|
|
|
173
181
|
status: "pending",
|
|
174
182
|
replaces_label: "ada",
|
|
175
183
|
replaces_hash: replaces,
|
|
184
|
+
ip: "203.0.113.50",
|
|
185
|
+
country: "XQ",
|
|
176
186
|
}],
|
|
177
187
|
},
|
|
178
188
|
});
|
|
179
189
|
const row = redacted.tables.device_requests?.[0] ?? {};
|
|
180
190
|
assert.equal(Object.hasOwn(row, "replaces_hash"), false);
|
|
191
|
+
assert.equal(Object.hasOwn(row, "ip"), false);
|
|
192
|
+
assert.equal(Object.hasOwn(row, "country"), false);
|
|
181
193
|
assert.equal(row.replaces_label, "ada");
|
|
182
194
|
assert.equal(row.device_hash, "redacted:WDJB4827");
|
|
183
195
|
const sql = toSql(redacted, { skip: REDACTED_SQL_SKIP });
|
|
184
196
|
assert.equal(sql.includes("replaces_hash"), false);
|
|
197
|
+
assert.equal(sql.includes("203.0.113.50"), false);
|
|
185
198
|
assert.equal(sql.includes(replaces), false);
|
|
186
199
|
assert.equal(JSON.stringify(redacted).includes(replaces), false);
|
|
187
200
|
});
|
|
188
201
|
|
|
202
|
+
test("self-host owner HTTP has no delete route", async () => {
|
|
203
|
+
const DB = db();
|
|
204
|
+
const env = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret" };
|
|
205
|
+
const res = await worker.fetch(new Request(BASE + "/owner/delete", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { authorization: "Bearer owner-secret" },
|
|
208
|
+
}) as never, env as never, ectx as never);
|
|
209
|
+
assert.equal(res.status, 404);
|
|
210
|
+
const tables = await DB.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'peers'").all<{ name: string }>();
|
|
211
|
+
assert.equal(tables.results.length, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
189
214
|
test("exportRows reads every application table in one batch", async () => {
|
|
190
215
|
const DB = db();
|
|
191
216
|
await DB.prepare("INSERT INTO peers (label, token_hash, created_at) VALUES ('ada', 'h', 't')").run();
|
|
@@ -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");
|
|
@@ -750,3 +761,58 @@ test("TenantStore.owner runs an allowlisted owner call and does not return a pee
|
|
|
750
761
|
assert.equal(await saw('"reason":"account"'), true);
|
|
751
762
|
assert.equal(lines.some((l) => l.includes("../acct")), false);
|
|
752
763
|
});
|
|
764
|
+
|
|
765
|
+
test("TenantStore.deleteStorage wipes the object and a second call succeeds", async (t) => {
|
|
766
|
+
const mf = await start({
|
|
767
|
+
bindings: {
|
|
768
|
+
TENANCY: "host",
|
|
769
|
+
TENANT_DOMAIN: "example.com",
|
|
770
|
+
OWNER_TOKEN: "platform-owner",
|
|
771
|
+
},
|
|
772
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants-delete" },
|
|
773
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
774
|
+
});
|
|
775
|
+
t.after(() => mf.dispose());
|
|
776
|
+
await mf.ready;
|
|
777
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
778
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
779
|
+
const hash = await sha256("owner-delta");
|
|
780
|
+
type Stub = {
|
|
781
|
+
pushConfig(body: Record<string, unknown>): Promise<{ version: number; applied: boolean }>;
|
|
782
|
+
deleteStorage(): Promise<{ deleted: boolean }>;
|
|
783
|
+
storageStatus(): Promise<{ configured: boolean; tables: string[] }>;
|
|
784
|
+
};
|
|
785
|
+
const stub = ns.get(ns.idFromName("id-delta")) as unknown as Stub;
|
|
786
|
+
await stub.pushConfig({
|
|
787
|
+
version: 1, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
|
|
788
|
+
config: { AGENT_NAME: "delta" },
|
|
789
|
+
});
|
|
790
|
+
await kv.put("tenant:delta", JSON.stringify({ id: "id-delta", status: "active", region: "default", version: 1 }));
|
|
791
|
+
const before = await stub.storageStatus();
|
|
792
|
+
assert.equal(before.configured, true);
|
|
793
|
+
assert.ok(before.tables.includes("tenant_config"));
|
|
794
|
+
const http = await mf.dispatchFetch("https://delta.example.com/owner/delete", {
|
|
795
|
+
method: "POST",
|
|
796
|
+
headers: { authorization: "Bearer owner-delta" },
|
|
797
|
+
});
|
|
798
|
+
assert.equal(http.status, 404);
|
|
799
|
+
assert.equal((await stub.storageStatus()).configured, true, "the owner HTTP API does not wipe storage");
|
|
800
|
+
const wiped = await stub.deleteStorage();
|
|
801
|
+
assert.equal(wiped.deleted, true);
|
|
802
|
+
const after = await stub.storageStatus();
|
|
803
|
+
assert.equal(after.configured, false);
|
|
804
|
+
assert.deepEqual(after.tables, []);
|
|
805
|
+
const again = await stub.deleteStorage();
|
|
806
|
+
assert.equal(again.deleted, true);
|
|
807
|
+
assert.equal((await stub.storageStatus()).configured, false);
|
|
808
|
+
assert.deepEqual((await stub.storageStatus()).tables, []);
|
|
809
|
+
const refused = await stub.pushConfig({
|
|
810
|
+
version: 2, tenantId: "id-delta", name: "delta", status: "active", ownerTokenHash: hash,
|
|
811
|
+
config: { AGENT_NAME: "delta" },
|
|
812
|
+
});
|
|
813
|
+
assert.equal(refused.applied, false);
|
|
814
|
+
assert.equal(refused.version, 0);
|
|
815
|
+
const stayed = await stub.storageStatus();
|
|
816
|
+
assert.equal(stayed.configured, false);
|
|
817
|
+
assert.deepEqual(stayed.tables, []);
|
|
818
|
+
});
|
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
|
});
|