a2a-exposed 0.4.1 → 0.6.0
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/README.md +3 -4
- package/lib/a2a.mjs +6 -2
- package/lib/commands.mjs +62 -12
- package/lib/deploy.mjs +42 -19
- package/lib/main.mjs +32 -11
- package/lib/pair.mjs +47 -3
- package/lib/status.mjs +4 -4
- package/lib/tunnel.mjs +11 -11
- package/lib/update.mjs +1 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +51 -7
- package/worker/deploy.env.example +28 -1
- package/worker/migrations/0006_mcp.sql +43 -0
- package/worker/migrations/0007_cimd.sql +10 -0
- package/worker/migrations/0008_oidc_txns.sql +12 -0
- package/worker/package.json +6 -2
- package/worker/src/approval-oidc.ts +247 -0
- package/worker/src/backup.ts +186 -0
- package/worker/src/directory.ts +120 -0
- package/worker/src/export.ts +156 -0
- package/worker/src/hosted.ts +403 -0
- package/worker/src/index.ts +1327 -446
- package/worker/src/mcp.ts +336 -0
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +18 -5
- package/worker/src/sql-raw.d.ts +5 -0
- package/worker/src/storage.ts +100 -0
- package/worker/src/tenancy.ts +450 -0
- package/worker/src/wake.ts +14 -2
- package/worker/test/alarms.test.ts +231 -0
- package/worker/test/approval-oidc.test.ts +331 -0
- package/worker/test/bridge-thread.mjs +40 -0
- package/worker/test/d1.ts +146 -2
- package/worker/test/directory.test.ts +90 -0
- package/worker/test/export.test.ts +544 -0
- package/worker/test/facade.test.ts +1 -1
- package/worker/test/harness-worker.ts +80 -0
- package/worker/test/hosted.test.ts +221 -0
- package/worker/test/mcp.test.ts +697 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +20 -7
- package/worker/test/register-hooks.mjs +4 -0
- package/worker/test/sql-raw-hook.mjs +22 -0
- package/worker/test/storage.test.ts +74 -0
- package/worker/test/tenancy.test.ts +217 -0
- package/worker/test/wake-retry.test.ts +74 -0
- package/worker/test/wake.test.ts +18 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Hosted Durable Object alarms replace the minute cron flush. Self-host scheduled() still runs that flush.
|
|
2
|
+
// Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
10
|
+
import worker, { cronFlush, nextCronAt } from "../src/index.ts";
|
|
11
|
+
import { d1 } from "./d1.ts";
|
|
12
|
+
import { sha256 } from "../src/a2a.ts";
|
|
13
|
+
import { resolveTenant } from "../src/tenancy.ts";
|
|
14
|
+
|
|
15
|
+
const MIGRATIONS = new URL("../migrations/", import.meta.url);
|
|
16
|
+
const ectx = { waitUntil(_p: Promise<unknown>) {}, passThroughOnException() {} };
|
|
17
|
+
|
|
18
|
+
function ctx(over: Record<string, string> = {}) {
|
|
19
|
+
const DB = d1(MIGRATIONS);
|
|
20
|
+
const env = { DB, PUBLIC_URL: "https://agent.example.com", OWNER_TOKEN: "owner-secret", WAKE_PRESET: "generic",
|
|
21
|
+
WAKE_DEBOUNCE_SECONDS: "10", WAKE_MAX_PER_HOUR: "0", ...over };
|
|
22
|
+
return { DB, env, tenant: resolveTenant(env as never) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("nextCronAt is the earliest wake, rate row, or expiry", async () => {
|
|
26
|
+
const { DB, tenant } = ctx();
|
|
27
|
+
assert.equal(await nextCronAt(tenant, 1_700_000_000_000), null);
|
|
28
|
+
const now = 1_700_000_000_000;
|
|
29
|
+
await DB.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('c', ?, '{\"kind\":\"inbound\"}', ?)").bind(now - 5000, now).run();
|
|
30
|
+
assert.equal(await nextCronAt(tenant, now), now - 5000 + 10_000);
|
|
31
|
+
await DB.prepare("INSERT INTO rate (peer, minute, count) VALUES ('p', 1, 2)").run();
|
|
32
|
+
assert.equal(await nextCronAt(tenant, now), 7 * 60_000);
|
|
33
|
+
await DB.prepare("DELETE FROM wakes").run();
|
|
34
|
+
await DB.prepare("DELETE FROM rate").run();
|
|
35
|
+
await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('h', 'c', 'https://example.com/cb', 'ch', 's', 'https://agent.example.com/mcp', 1, ?)").bind(now + 5000).run();
|
|
36
|
+
await DB.prepare("INSERT INTO device_requests (device_hash, user_code, status, created_ms, expires_ms, interval_s) VALUES ('d', 'WDJB4827', 'pending', 1, ?, 5)").bind(now).run();
|
|
37
|
+
assert.equal(await nextCronAt(tenant, now), now + 5001);
|
|
38
|
+
await DB.prepare("DELETE FROM oauth_codes").run();
|
|
39
|
+
await DB.prepare("DELETE FROM device_requests").run();
|
|
40
|
+
await DB.prepare("INSERT INTO oidc_txns (state, nonce, verifier, kind, csrf, payload_json, expires_ms) VALUES ('s', 'n', 'v', 'device', 'c', '{}', ?)").bind(now + 800).run();
|
|
41
|
+
assert.equal(await nextCronAt(tenant, now), now + 801);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("an hourly cap holds an already-due wake until the next hour", async () => {
|
|
45
|
+
const now = 1_700_000_000_000;
|
|
46
|
+
const hour = Math.floor(now / 3600000);
|
|
47
|
+
const { DB, tenant } = ctx({ WAKE_MAX_PER_HOUR: "2" });
|
|
48
|
+
await DB.prepare("INSERT INTO wake_budget (hour, count) VALUES (?, 2)").bind(hour).run();
|
|
49
|
+
await DB.prepare("INSERT INTO wakes (context_id, last_sent_ms, pending_json, pending_since_ms) VALUES ('c', ?, '{}', ?)").bind(now - 60_000, now).run();
|
|
50
|
+
assert.equal(await nextCronAt(tenant, now), (hour + 1) * 3600000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("self-host scheduled still deletes expired housekeeping rows", async () => {
|
|
54
|
+
const { DB, env, tenant } = ctx();
|
|
55
|
+
const minute = Math.floor(Date.now() / 60000);
|
|
56
|
+
await DB.prepare("INSERT INTO rate (peer, minute, count) VALUES ('old', 1, 3)").run();
|
|
57
|
+
await DB.prepare("INSERT INTO rate (peer, minute, count) VALUES ('new', ?, 1)").bind(minute).run();
|
|
58
|
+
await DB.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, code_challenge, scope, resource, created_ms, expires_ms) VALUES ('h', 'c', 'https://example.com/cb', 'ch', 's', 'https://agent.example.com/mcp', 1, 1)").run();
|
|
59
|
+
await DB.prepare("INSERT INTO oidc_txns (state, nonce, verifier, kind, csrf, payload_json, expires_ms) VALUES ('s', 'n', 'v', 'device', 'c', '{}', 1)").run();
|
|
60
|
+
await worker.scheduled({ cron: "* * * * *" } as never, env as never, ectx as never);
|
|
61
|
+
assert.equal(await DB.prepare("SELECT peer FROM rate WHERE peer = 'old'").first(), null);
|
|
62
|
+
assert.equal(await DB.prepare("SELECT state FROM oidc_txns").first(), null);
|
|
63
|
+
assert.equal((await DB.prepare("SELECT peer FROM rate WHERE peer = 'new'").first<{ peer: string }>())?.peer, "new");
|
|
64
|
+
assert.equal(await DB.prepare("SELECT code_hash FROM oauth_codes").first(), null);
|
|
65
|
+
await cronFlush(tenant, ectx as never);
|
|
66
|
+
assert.equal((await DB.prepare("SELECT peer FROM rate WHERE peer = 'new'").first<{ peer: string }>())?.peer, "new");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const require = createRequire(new URL("../package.json", import.meta.url));
|
|
70
|
+
|
|
71
|
+
function bundleHosted(): Promise<string> {
|
|
72
|
+
const { build } = require("esbuild") as typeof import("esbuild");
|
|
73
|
+
return build({
|
|
74
|
+
entryPoints: [fileURLToPath(new URL("../src/hosted.ts", import.meta.url))],
|
|
75
|
+
bundle: true,
|
|
76
|
+
format: "esm",
|
|
77
|
+
write: false,
|
|
78
|
+
platform: "neutral",
|
|
79
|
+
external: ["cloudflare:*"],
|
|
80
|
+
target: "es2022",
|
|
81
|
+
logLevel: "silent",
|
|
82
|
+
plugins: [{
|
|
83
|
+
name: "sql-raw",
|
|
84
|
+
setup(b) {
|
|
85
|
+
b.onResolve({ filter: /\.sql\?raw$/ }, (args) => ({
|
|
86
|
+
path: path.resolve(args.resolveDir, args.path.replace(/\?raw$/, "")),
|
|
87
|
+
namespace: "sql-raw",
|
|
88
|
+
}));
|
|
89
|
+
b.onLoad({ filter: /.*/, namespace: "sql-raw" }, (args) => ({
|
|
90
|
+
contents: `export default ${JSON.stringify(fs.readFileSync(args.path, "utf8"))};`,
|
|
91
|
+
loader: "js",
|
|
92
|
+
}));
|
|
93
|
+
},
|
|
94
|
+
}],
|
|
95
|
+
}).then((built) => {
|
|
96
|
+
const file = built.outputFiles?.[0];
|
|
97
|
+
if (!file) throw new Error("hosted bundle was empty");
|
|
98
|
+
return file.text;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("a hosted tenant arms an alarm for a debounced wake and the alarm flushes it", async (t) => {
|
|
103
|
+
const mf = new Miniflare(convertV4MiniflareOptions({
|
|
104
|
+
name: "a2a",
|
|
105
|
+
modules: true,
|
|
106
|
+
script: await bundleHosted(),
|
|
107
|
+
compatibilityDate: "2026-10-06",
|
|
108
|
+
d1Databases: { DB: "unused" },
|
|
109
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants" },
|
|
110
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
111
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret", OWNER_TOKEN: "platform-owner" },
|
|
112
|
+
} as never));
|
|
113
|
+
t.after(() => mf.dispose());
|
|
114
|
+
await mf.ready;
|
|
115
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
116
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
117
|
+
const hash = await sha256("owner-alice");
|
|
118
|
+
const stub = ns.get(ns.idFromName("id-alice")) as unknown as {
|
|
119
|
+
pushConfig(b: Record<string, unknown>): Promise<{ applied: boolean }>;
|
|
120
|
+
alarmAt(): Promise<number | null>;
|
|
121
|
+
runAlarm(): Promise<void>;
|
|
122
|
+
fetch(r: Request): Promise<Response>;
|
|
123
|
+
};
|
|
124
|
+
assert.equal(await stub.alarmAt(), null);
|
|
125
|
+
// 30s is past the 25s in-request flush. The second message only writes a pending row, so the alarm has to move off the rate-row timer in that same request.
|
|
126
|
+
await stub.pushConfig({ version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hash, config: { WAKE_DEBOUNCE_SECONDS: "30", WAKE_MAX_PER_HOUR: "0", AGENT_NAME: "Alice" } });
|
|
127
|
+
await kv.put("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "default", version: 1 }));
|
|
128
|
+
assert.equal(await stub.alarmAt(), null, "an idle tenant has nothing to flush");
|
|
129
|
+
const send = (text: string, id: string) => mf.dispatchFetch("https://alice.example.com/", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
132
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: id, contextId: "ctx-a", parts: [{ kind: "text", text }] } } }),
|
|
133
|
+
});
|
|
134
|
+
const issued = await (await mf.dispatchFetch("https://alice.example.com/owner/peers", {
|
|
135
|
+
method: "POST", headers: { authorization: "Bearer owner-alice", "content-type": "application/json" }, body: JSON.stringify({ label: "ada" }),
|
|
136
|
+
})).json() as { token: string };
|
|
137
|
+
const token = issued.token;
|
|
138
|
+
assert.equal((await send("one", "m1")).status, 200);
|
|
139
|
+
const rateAlarm = await stub.alarmAt();
|
|
140
|
+
assert.ok(rateAlarm && rateAlarm > Date.now() + 4 * 60_000 && rateAlarm < Date.now() + 7 * 60_000, "the request's rate row is the next housekeeping delete");
|
|
141
|
+
assert.equal((await send("two", "m2")).status, 200);
|
|
142
|
+
const armed = await stub.alarmAt();
|
|
143
|
+
assert.ok(armed && armed > Date.now() + 20_000 && armed < Date.now() + 45_000, "the pending wake is due before the rate row");
|
|
144
|
+
|
|
145
|
+
// "0" is a real debounce of zero (the string is set). A runtime may run that alarm before this RPC returns.
|
|
146
|
+
await stub.pushConfig({ version: 2, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hash, config: { WAKE_DEBOUNCE_SECONDS: "0", WAKE_MAX_PER_HOUR: "0", AGENT_NAME: "Alice" } });
|
|
147
|
+
let after = await stub.alarmAt();
|
|
148
|
+
if (after && after <= Date.now() + 2000) {
|
|
149
|
+
await stub.runAlarm();
|
|
150
|
+
after = await stub.alarmAt();
|
|
151
|
+
}
|
|
152
|
+
assert.ok(after && after > Date.now() + 60_000 && after < Date.now() + 7 * 60_000, "the alarm flushed the wake; the rate row is still waiting");
|
|
153
|
+
const inbox = await (await mf.dispatchFetch("https://alice.example.com/owner/inbox?all=1", {
|
|
154
|
+
headers: { authorization: "Bearer owner-alice" },
|
|
155
|
+
})).json() as { text: string }[];
|
|
156
|
+
assert.equal(inbox.length, 2);
|
|
157
|
+
|
|
158
|
+
await stub.pushConfig({ version: 3, tenantId: "id-alice", name: "alice", status: "suspended", ownerTokenHash: hash, config: { AGENT_NAME: "Alice" } });
|
|
159
|
+
assert.equal(await stub.alarmAt(), null, "a suspended tenant has no alarm");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a 429 that arrives after the response re-arms for Retry-After", async (t) => {
|
|
163
|
+
const { createServer } = await import("node:http");
|
|
164
|
+
const hits: number[] = [];
|
|
165
|
+
// Hold the 429 until the inbox has responded, so the retry row is written after fetch returns.
|
|
166
|
+
let release: () => void = () => {};
|
|
167
|
+
const hold = new Promise<void>((resolve) => { release = resolve; });
|
|
168
|
+
const server = createServer((_req, res) => {
|
|
169
|
+
hits.push(Date.now());
|
|
170
|
+
void hold.then(() => {
|
|
171
|
+
if (res.writableEnded) return;
|
|
172
|
+
res.writeHead(429, { "retry-after": "30" });
|
|
173
|
+
res.end("slow");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
177
|
+
const port = (server.address() as { port: number }).port;
|
|
178
|
+
t.after(() => new Promise((resolve) => server.close(() => resolve(undefined))));
|
|
179
|
+
const mf = new Miniflare(convertV4MiniflareOptions({
|
|
180
|
+
name: "a2a",
|
|
181
|
+
modules: true,
|
|
182
|
+
script: await bundleHosted(),
|
|
183
|
+
compatibilityDate: "2026-10-06",
|
|
184
|
+
d1Databases: { DB: "unused" },
|
|
185
|
+
kvNamespaces: { TENANT_DIRECTORY: "tenants" },
|
|
186
|
+
durableObjects: { TENANT_DO: { className: "TenantStore", useSQLite: true } },
|
|
187
|
+
bindings: { TENANCY: "host", TENANT_DOMAIN: "example.com", TENANT_SECRETS_KEY: "platform-secret", OWNER_TOKEN: "platform-owner" },
|
|
188
|
+
} as never));
|
|
189
|
+
t.after(() => mf.dispose());
|
|
190
|
+
await mf.ready;
|
|
191
|
+
const kv = await mf.getKVNamespace("TENANT_DIRECTORY");
|
|
192
|
+
const ns = await mf.getDurableObjectNamespace("TENANT_DO");
|
|
193
|
+
const hash = await sha256("owner-alice");
|
|
194
|
+
const stub = ns.get(ns.idFromName("id-alice")) as unknown as {
|
|
195
|
+
pushConfig(b: Record<string, unknown>): Promise<{ applied: boolean }>;
|
|
196
|
+
alarmAt(): Promise<number | null>;
|
|
197
|
+
};
|
|
198
|
+
await stub.pushConfig({
|
|
199
|
+
version: 1, tenantId: "id-alice", name: "alice", status: "active", ownerTokenHash: hash,
|
|
200
|
+
config: { WAKE_DEBOUNCE_SECONDS: "45", WAKE_MAX_PER_HOUR: "0", WAKE_WEBHOOK_URL: `http://127.0.0.1:${port}/hook`, AGENT_NAME: "Alice" },
|
|
201
|
+
});
|
|
202
|
+
await kv.put("tenant:alice", JSON.stringify({ id: "id-alice", status: "active", region: "default", version: 1 }));
|
|
203
|
+
const issued = await (await mf.dispatchFetch("https://alice.example.com/owner/peers", {
|
|
204
|
+
method: "POST", headers: { authorization: "Bearer owner-alice", "content-type": "application/json" }, body: JSON.stringify({ label: "ada" }),
|
|
205
|
+
})).json() as { token: string };
|
|
206
|
+
const sentPromise = mf.dispatchFetch("https://alice.example.com/", {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: { authorization: `Bearer ${issued.token}`, "content-type": "application/json" },
|
|
209
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: { message: { kind: "message", role: "user", messageId: "m1", contextId: "ctx-a", parts: [{ kind: "text", text: "one" }] } } }),
|
|
210
|
+
});
|
|
211
|
+
const waitHit = Date.now() + 4000;
|
|
212
|
+
while (!hits.length && Date.now() < waitHit) await new Promise((r) => setTimeout(r, 20));
|
|
213
|
+
assert.equal(hits.length >= 1, true, "the webhook was called");
|
|
214
|
+
const earlyResponse = await Promise.race([
|
|
215
|
+
sentPromise.then((r) => r),
|
|
216
|
+
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)),
|
|
217
|
+
]);
|
|
218
|
+
assert.ok(earlyResponse, "the inbox returns while the webhook is still held");
|
|
219
|
+
assert.equal(earlyResponse.status, 200);
|
|
220
|
+
const before = await stub.alarmAt();
|
|
221
|
+
assert.ok(before == null || before > Date.now() + 60_000, "the retry alarm is not set before the 429 is written");
|
|
222
|
+
release();
|
|
223
|
+
const start = Date.now();
|
|
224
|
+
let armed: number | null = null;
|
|
225
|
+
while (Date.now() - start < 4000) {
|
|
226
|
+
armed = await stub.alarmAt();
|
|
227
|
+
if (armed && armed < Date.now() + 60_000) break;
|
|
228
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
229
|
+
}
|
|
230
|
+
assert.ok(armed && armed > Date.now() + 20_000 && armed < Date.now() + 40_000, "the alarm is Retry-After (30s), not that delay plus the 45s debounce");
|
|
231
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// OpenID Connect approval on /device and /oauth/authorize, against a stub issuer (discovery, token, JWKS).
|
|
2
|
+
// Run: npm test
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import worker, { dispatch } from "../src/index.ts";
|
|
7
|
+
import * as M from "../src/mcp.ts";
|
|
8
|
+
import { approvalPolicy } from "../src/approval-oidc.ts";
|
|
9
|
+
import { hostedTenantContext, parseGates, type WorkerBindings } from "../src/tenancy.ts";
|
|
10
|
+
import { d1 } from "./d1.ts";
|
|
11
|
+
|
|
12
|
+
const BASE = "https://agent.example.com";
|
|
13
|
+
const ISSUER = "https://idp.example";
|
|
14
|
+
const CLIENT = "client-1";
|
|
15
|
+
const SECRET = "oidc-client-secret";
|
|
16
|
+
const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
17
|
+
|
|
18
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
19
|
+
const jwk = publicKey.export({ format: "jwk" }) as { n: string; e: string; kty: string };
|
|
20
|
+
Object.assign(jwk, { kid: "k1", alg: "RS256", use: "sig" });
|
|
21
|
+
|
|
22
|
+
function signJwt(payload: Record<string, unknown>, header: Record<string, unknown> = { alg: "RS256", kid: "k1", typ: "JWT" }): string {
|
|
23
|
+
const h = Buffer.from(JSON.stringify(header)).toString("base64url");
|
|
24
|
+
const b = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
25
|
+
const data = `${h}.${b}`;
|
|
26
|
+
if (header.alg === "none") return `${data}.`;
|
|
27
|
+
const sig = crypto.createSign("RSA-SHA256").update(data).sign(privateKey).toString("base64url");
|
|
28
|
+
return `${data}.${sig}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function claims(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
32
|
+
const now = Math.floor(Date.now() / 1000);
|
|
33
|
+
return { iss: ISSUER, aud: [CLIENT, "other"], azp: CLIENT, sub: "owner-1", exp: now + 300, iat: now, ...over };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setup(over: Record<string, string> = {}) {
|
|
37
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
38
|
+
const env: any = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", AGENT_NAME: "Test Inbox", ...over };
|
|
39
|
+
const tokenBodies: Record<string, string>[] = [];
|
|
40
|
+
let next: () => Record<string, unknown> = () => claims();
|
|
41
|
+
const logs: string[] = [];
|
|
42
|
+
const origLog = console.log;
|
|
43
|
+
console.log = (line: string) => { logs.push(String(line)); };
|
|
44
|
+
const origFetch = globalThis.fetch;
|
|
45
|
+
globalThis.fetch = (async (url: any, init: any = {}) => {
|
|
46
|
+
const u = new URL(String(url));
|
|
47
|
+
if (u.origin === ISSUER && u.pathname === "/.well-known/openid-configuration")
|
|
48
|
+
return Response.json({ issuer: ISSUER, authorization_endpoint: `${ISSUER}/authorize`, token_endpoint: `${ISSUER}/token`, jwks_uri: `${ISSUER}/jwks` });
|
|
49
|
+
if (u.origin === ISSUER && u.pathname === "/jwks") return Response.json({ keys: [jwk] });
|
|
50
|
+
if (u.origin === ISSUER && u.pathname === "/token") {
|
|
51
|
+
tokenBodies.push(Object.fromEntries(new URLSearchParams(String(init.body || ""))));
|
|
52
|
+
return Response.json({ id_token: signJwt(next()) });
|
|
53
|
+
}
|
|
54
|
+
return new Response("ok");
|
|
55
|
+
}) as any;
|
|
56
|
+
const call = async (method: string, path: string, o: { form?: Record<string, string>; json?: any; headers?: Record<string, string>; cookie?: string } = {}) => {
|
|
57
|
+
const headers: Record<string, string> = { "cf-connecting-ip": "198.51.100.7", ...(o.headers || {}) };
|
|
58
|
+
let body: string | undefined;
|
|
59
|
+
if (o.form) { headers["content-type"] = "application/x-www-form-urlencoded"; body = new URLSearchParams(o.form).toString(); }
|
|
60
|
+
if (o.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(o.json); }
|
|
61
|
+
if (o.cookie) headers.cookie = o.cookie;
|
|
62
|
+
const res = await worker.fetch(new Request(BASE + path, { method, headers, body }) as any, env, { waitUntil() {}, passThroughOnException() {} } as any);
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
let data: any = text;
|
|
65
|
+
try { data = JSON.parse(text); } catch { /* html */ }
|
|
66
|
+
return { status: res.status, headers: res.headers, data, text };
|
|
67
|
+
};
|
|
68
|
+
const csrfOf = (text: string, setCookie: string | null) => /name="csrf" value="([^"]+)"/.exec(text)?.[1] || (setCookie || "").split(";")[0].split("=")[1] || "";
|
|
69
|
+
const start = () => call("POST", "/oauth/device_authorization", { form: { client_name: "Barry Bot", client_id: "barry" } });
|
|
70
|
+
const page = async (userCode: string) => {
|
|
71
|
+
const g = await call("GET", `/device?user_code=${userCode}`);
|
|
72
|
+
const cookie = (g.headers.get("set-cookie") || "").split(";")[0];
|
|
73
|
+
return { ...g, cookie, csrf: csrfOf(g.text, g.headers.get("set-cookie")) };
|
|
74
|
+
};
|
|
75
|
+
const postOidc = async (userCode: string) => {
|
|
76
|
+
const g = await page(userCode);
|
|
77
|
+
return { page: g, post: await call("POST", "/device", { form: { csrf: g.csrf, user_code: userCode, decision: "oidc" }, cookie: g.cookie, headers: { origin: BASE } }) };
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
env, DB, tokenBodies, logs, call, start, page, postOidc,
|
|
81
|
+
setClaims(fn: () => Record<string, unknown>) { next = fn; },
|
|
82
|
+
restore: () => { globalThis.fetch = origFetch; console.log = origLog; },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const oidcEnv = {
|
|
87
|
+
APPROVAL_OIDC_ISSUER: ISSUER, APPROVAL_OIDC_CLIENT_ID: CLIENT, APPROVAL_OIDC_CLIENT_SECRET: SECRET,
|
|
88
|
+
APPROVAL_OIDC_ALLOWED_SUBJECTS: "owner-1",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
test("approval policy: unset is password only; incomplete oidc does not disable the password", () => {
|
|
92
|
+
const off = parseGates({ DB: {} } as WorkerBindings).gates.approvalOidc;
|
|
93
|
+
assert.deepEqual(approvalPolicy(off), { oidc: false, password: true, label: "" });
|
|
94
|
+
const partial = parseGates({ DB: {}, APPROVAL_OIDC_ISSUER: ISSUER, APPROVAL_OIDC_CLIENT_ID: CLIENT, APPROVAL_METHODS: "oidc" } as WorkerBindings).gates.approvalOidc;
|
|
95
|
+
assert.equal(approvalPolicy(partial).oidc, false);
|
|
96
|
+
assert.equal(approvalPolicy(partial).password, true, "oidc-only with a missing secret keeps the password");
|
|
97
|
+
const both = parseGates({ DB: {}, ...oidcEnv } as WorkerBindings).gates.approvalOidc;
|
|
98
|
+
assert.equal(approvalPolicy(both).oidc, true);
|
|
99
|
+
assert.equal(approvalPolicy(both).password, true);
|
|
100
|
+
assert.equal(approvalPolicy(both).label, "idp.example");
|
|
101
|
+
const explicit = parseGates({ DB: {}, ...oidcEnv, APPROVAL_METHODS: "password" } as WorkerBindings).gates.approvalOidc;
|
|
102
|
+
assert.equal(approvalPolicy(explicit).oidc, false);
|
|
103
|
+
assert.equal(approvalPolicy(explicit).password, true);
|
|
104
|
+
const only = parseGates({ DB: {}, ...oidcEnv, APPROVAL_METHODS: "oidc" } as WorkerBindings).gates.approvalOidc;
|
|
105
|
+
assert.equal(approvalPolicy(only).password, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("unset /device has no OpenID Connect button", async (t) => {
|
|
109
|
+
const s = setup(); t.after(s.restore);
|
|
110
|
+
const d = await s.start();
|
|
111
|
+
const g = await s.page(d.data.user_code);
|
|
112
|
+
assert.equal(g.status, 200);
|
|
113
|
+
assert.equal(g.text.includes("Approve with"), false);
|
|
114
|
+
assert.equal(g.text.includes("Continue with"), false);
|
|
115
|
+
assert.match(g.text, /No approval password is set yet/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("configured /device redirects to the issuer with PKCE and approves on a valid id token", async (t) => {
|
|
119
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
120
|
+
const d = await s.start();
|
|
121
|
+
const code = d.data.user_code as string;
|
|
122
|
+
const { page, post } = await s.postOidc(code);
|
|
123
|
+
assert.match(page.text, /Approve with idp\.example/);
|
|
124
|
+
assert.match(page.text, /No approval password is set yet/);
|
|
125
|
+
assert.equal(post.status, 302, post.text);
|
|
126
|
+
const loc = new URL(post.headers.get("location")!);
|
|
127
|
+
assert.equal(loc.origin + loc.pathname, `${ISSUER}/authorize`);
|
|
128
|
+
assert.equal(loc.searchParams.get("code_challenge_method"), "S256");
|
|
129
|
+
assert.equal(loc.searchParams.get("scope"), "openid email");
|
|
130
|
+
assert.equal(loc.searchParams.get("redirect_uri"), `${BASE}/device/oidc/callback`);
|
|
131
|
+
assert.ok(loc.searchParams.get("state"));
|
|
132
|
+
assert.ok(loc.searchParams.get("nonce"));
|
|
133
|
+
assert.equal(loc.search.includes(code.replace("-", "")), false);
|
|
134
|
+
assert.equal(loc.search.includes(SECRET), false);
|
|
135
|
+
const nonce = loc.searchParams.get("nonce")!;
|
|
136
|
+
s.setClaims(() => claims({ nonce }));
|
|
137
|
+
const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
138
|
+
assert.equal(back.status, 200, back.text);
|
|
139
|
+
assert.match(back.text, /Approved/);
|
|
140
|
+
assert.equal(s.tokenBodies.length, 1);
|
|
141
|
+
assert.equal(s.tokenBodies[0].client_secret, SECRET);
|
|
142
|
+
assert.equal(s.tokenBodies[0].redirect_uri, `${BASE}/device/oidc/callback`);
|
|
143
|
+
assert.equal(loc.searchParams.get("code_challenge"), await M.s256(s.tokenBodies[0].code_verifier));
|
|
144
|
+
const again = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
145
|
+
assert.equal(again.status, 400);
|
|
146
|
+
assert.match(again.text, /already used/);
|
|
147
|
+
const tok = await s.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
|
|
148
|
+
assert.equal(tok.status, 200, tok.text);
|
|
149
|
+
assert.ok(String(tok.data.access_token).startsWith("a2aow_"));
|
|
150
|
+
assert.equal(s.logs.join("\n").includes(SECRET), false);
|
|
151
|
+
const list = await s.call("GET", "/owner/pairing", { headers: { authorization: "Bearer owner-secret" } });
|
|
152
|
+
assert.deepEqual(list.data.approvalMethods, ["password", "oidc"]);
|
|
153
|
+
assert.equal(list.data.oidcIssuer, "idp.example");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("wrong sub, wrong nonce and expired exp do not approve", async (t) => {
|
|
157
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
158
|
+
const bad = async (over: Record<string, unknown>) => {
|
|
159
|
+
const d = await s.start();
|
|
160
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
161
|
+
const loc = new URL(post.headers.get("location")!);
|
|
162
|
+
const nonce = loc.searchParams.get("nonce")!;
|
|
163
|
+
s.setClaims(() => claims({ nonce, ...over }));
|
|
164
|
+
const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
165
|
+
assert.equal(back.status, 400, back.text);
|
|
166
|
+
const tok = await s.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
|
|
167
|
+
assert.equal(tok.data.error, "authorization_pending");
|
|
168
|
+
};
|
|
169
|
+
await bad({ sub: "someone-else" });
|
|
170
|
+
await bad({ nonce: "not-the-nonce" });
|
|
171
|
+
await bad({ exp: Math.floor(Date.now() / 1000) - 120 });
|
|
172
|
+
s.setClaims(() => claims());
|
|
173
|
+
const d = await s.start();
|
|
174
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
175
|
+
const loc = new URL(post.headers.get("location")!);
|
|
176
|
+
const none = signJwt(claims({ nonce: loc.searchParams.get("nonce") }), { alg: "none", typ: "JWT" });
|
|
177
|
+
const orig = globalThis.fetch;
|
|
178
|
+
globalThis.fetch = (async (url: any, init: any = {}) => {
|
|
179
|
+
const u = new URL(String(url));
|
|
180
|
+
if (u.pathname === "/token") return Response.json({ id_token: none });
|
|
181
|
+
return orig(url, init);
|
|
182
|
+
}) as any;
|
|
183
|
+
const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
184
|
+
globalThis.fetch = orig;
|
|
185
|
+
assert.equal(back.status, 400, back.text);
|
|
186
|
+
const tok = await s.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
|
|
187
|
+
assert.equal(tok.data.error, "authorization_pending");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("APPROVAL_METHODS=oidc hides the password; APPROVAL_METHODS=password hides OpenID Connect", async (t) => {
|
|
191
|
+
const only = setup({ ...oidcEnv, APPROVAL_METHODS: "oidc" }); t.after(only.restore);
|
|
192
|
+
const d = await only.start();
|
|
193
|
+
const g = await only.page(d.data.user_code);
|
|
194
|
+
assert.equal(g.text.includes('name="password"'), false);
|
|
195
|
+
assert.equal(g.text.includes("No approval password is set yet"), false);
|
|
196
|
+
assert.match(g.text, /Approve with idp\.example/);
|
|
197
|
+
assert.match(g.text, /Deny/);
|
|
198
|
+
const posted = await only.call("POST", "/device", { form: { csrf: g.csrf, user_code: d.data.user_code, decision: "approve", password: "correct horse battery staple" }, cookie: g.cookie, headers: { origin: BASE } });
|
|
199
|
+
assert.equal(posted.status, 400, posted.text);
|
|
200
|
+
const still = await only.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
|
|
201
|
+
assert.equal(still.data.error, "authorization_pending");
|
|
202
|
+
|
|
203
|
+
const pw = setup({ ...oidcEnv, APPROVAL_METHODS: "password" }); t.after(pw.restore);
|
|
204
|
+
const d2 = await pw.start();
|
|
205
|
+
const g2 = await pw.page(d2.data.user_code);
|
|
206
|
+
assert.equal(g2.text.includes("Approve with"), false);
|
|
207
|
+
assert.match(g2.text, /No approval password is set yet/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("an unverified email does not approve; sub does; multi-audience tokens need azp", async (t) => {
|
|
211
|
+
const s = setup({ ...oidcEnv, APPROVAL_OIDC_ALLOWED_SUBJECTS: "human@example.com" }); t.after(s.restore);
|
|
212
|
+
const reject = async (over: Record<string, unknown>, dropAzp = false) => {
|
|
213
|
+
const d = await s.start();
|
|
214
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
215
|
+
const loc = new URL(post.headers.get("location")!);
|
|
216
|
+
const nonce = loc.searchParams.get("nonce")!;
|
|
217
|
+
const body = claims({ nonce, sub: "other", email: "human@example.com", ...over });
|
|
218
|
+
if (dropAzp) delete body.azp;
|
|
219
|
+
s.setClaims(() => body);
|
|
220
|
+
const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
221
|
+
assert.equal(back.status, 400, back.text);
|
|
222
|
+
const tok = await s.call("POST", "/oauth/token", { form: { grant_type: GRANT, device_code: d.data.device_code } });
|
|
223
|
+
assert.equal(tok.data.error, "authorization_pending");
|
|
224
|
+
};
|
|
225
|
+
await reject({});
|
|
226
|
+
await reject({ email_verified: false });
|
|
227
|
+
await reject({ email_verified: "true" });
|
|
228
|
+
await reject({ email_verified: true }, true);
|
|
229
|
+
|
|
230
|
+
const asSub = setup({ ...oidcEnv, APPROVAL_OIDC_ALLOWED_SUBJECTS: "human@example.com" }); t.after(asSub.restore);
|
|
231
|
+
const dSub = await asSub.start();
|
|
232
|
+
const started = await asSub.postOidc(dSub.data.user_code);
|
|
233
|
+
const locSub = new URL(started.post.headers.get("location")!);
|
|
234
|
+
asSub.setClaims(() => claims({ nonce: locSub.searchParams.get("nonce")!, sub: "human@example.com", email: "other@example.com", email_verified: false }));
|
|
235
|
+
const viaSub = await asSub.call("GET", `/device/oidc/callback?code=auth-code&state=${locSub.searchParams.get("state")}`);
|
|
236
|
+
assert.equal(viaSub.status, 200, viaSub.text);
|
|
237
|
+
|
|
238
|
+
const single = setup(oidcEnv); t.after(single.restore);
|
|
239
|
+
const d1 = await single.start();
|
|
240
|
+
const p1 = await single.postOidc(d1.data.user_code);
|
|
241
|
+
const loc1 = new URL(p1.post.headers.get("location")!);
|
|
242
|
+
single.setClaims(() => {
|
|
243
|
+
const body = claims({ nonce: loc1.searchParams.get("nonce")!, aud: CLIENT });
|
|
244
|
+
delete body.azp;
|
|
245
|
+
return body;
|
|
246
|
+
});
|
|
247
|
+
const okSingle = await single.call("GET", `/device/oidc/callback?code=auth-code&state=${loc1.searchParams.get("state")}`);
|
|
248
|
+
assert.equal(okSingle.status, 200, okSingle.text);
|
|
249
|
+
|
|
250
|
+
const wrong = setup(oidcEnv); t.after(wrong.restore);
|
|
251
|
+
const d2 = await wrong.start();
|
|
252
|
+
const p2 = await wrong.postOidc(d2.data.user_code);
|
|
253
|
+
const loc2 = new URL(p2.post.headers.get("location")!);
|
|
254
|
+
wrong.setClaims(() => {
|
|
255
|
+
const body = claims({ nonce: loc2.searchParams.get("nonce")!, aud: CLIENT, azp: "someone-else" });
|
|
256
|
+
return body;
|
|
257
|
+
});
|
|
258
|
+
const badAzp = await wrong.call("GET", `/device/oidc/callback?code=auth-code&state=${loc2.searchParams.get("state")}`);
|
|
259
|
+
assert.equal(badAzp.status, 400, badAzp.text);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("email allowlist matches and a hosted tenant uses its own approval, not the Worker secret", async (t) => {
|
|
263
|
+
const s = setup({ ...oidcEnv, APPROVAL_OIDC_ALLOWED_SUBJECTS: "human@example.com" }); t.after(s.restore);
|
|
264
|
+
const d = await s.start();
|
|
265
|
+
const { post } = await s.postOidc(d.data.user_code);
|
|
266
|
+
const loc = new URL(post.headers.get("location")!);
|
|
267
|
+
s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")!, sub: "other", email: "human@example.com", email_verified: true }));
|
|
268
|
+
const back = await s.call("GET", `/device/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
269
|
+
assert.equal(back.status, 200, back.text);
|
|
270
|
+
assert.match(back.text, /Approved/);
|
|
271
|
+
|
|
272
|
+
const DB = d1(new URL("../migrations/", import.meta.url));
|
|
273
|
+
const platform: any = { DB, PUBLIC_URL: BASE, OWNER_TOKEN: "owner-secret", ...oidcEnv, APPROVAL_OIDC_CLIENT_SECRET: "platform-secret", APPROVAL_METHODS: "oidc" };
|
|
274
|
+
const ctx = hostedTenantContext(platform, {
|
|
275
|
+
id: "ten_a", db: DB, publicUrl: BASE,
|
|
276
|
+
approval: { issuer: ISSUER, clientId: CLIENT, clientSecret: "tenant-secret", allowedSubjects: ["owner-1"], label: "Example IdP" },
|
|
277
|
+
});
|
|
278
|
+
const ectx = { waitUntil() {}, passThroughOnException() {} } as any;
|
|
279
|
+
const call = async (method: string, path: string, o: { form?: Record<string, string>; headers?: Record<string, string>; cookie?: string } = {}) => {
|
|
280
|
+
const headers: Record<string, string> = { "cf-connecting-ip": "198.51.100.7", ...(o.headers || {}) };
|
|
281
|
+
let body: string | undefined;
|
|
282
|
+
if (o.form) { headers["content-type"] = "application/x-www-form-urlencoded"; body = new URLSearchParams(o.form).toString(); }
|
|
283
|
+
if (o.cookie) headers.cookie = o.cookie;
|
|
284
|
+
const res = await dispatch(new Request(BASE + path, { method, headers, body }), ctx, ectx);
|
|
285
|
+
const text = await res.text();
|
|
286
|
+
let data: any = text;
|
|
287
|
+
try { data = JSON.parse(text); } catch { /* html */ }
|
|
288
|
+
return { status: res.status, headers: res.headers, text, data };
|
|
289
|
+
};
|
|
290
|
+
const started = await call("POST", "/oauth/device_authorization", { form: { client_name: "Barry", client_id: "barry" } });
|
|
291
|
+
const g = await call("GET", `/device?user_code=${started.data.user_code}`);
|
|
292
|
+
assert.match(g.text, /Approve with Example IdP/);
|
|
293
|
+
assert.equal(g.text.includes("platform"), false);
|
|
294
|
+
const cookie = (g.headers.get("set-cookie") || "").split(";")[0];
|
|
295
|
+
const csrf = /name="csrf" value="([^"]+)"/.exec(g.text)![1];
|
|
296
|
+
const startedPost = await call("POST", "/device", { form: { csrf, user_code: started.data.user_code, decision: "oidc" }, cookie, headers: { origin: BASE } });
|
|
297
|
+
assert.equal(startedPost.status, 302, startedPost.text);
|
|
298
|
+
const loc2 = new URL(startedPost.headers.get("location")!);
|
|
299
|
+
s.setClaims(() => claims({ nonce: loc2.searchParams.get("nonce")! }));
|
|
300
|
+
const done = await call("GET", `/device/oidc/callback?code=auth-code&state=${loc2.searchParams.get("state")}`);
|
|
301
|
+
assert.match(done.text, /Approved/);
|
|
302
|
+
assert.equal(s.tokenBodies.at(-1)!.client_secret, "tenant-secret");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("MCP consent offers Continue with the issuer and the callback issues the authorization code", async (t) => {
|
|
306
|
+
const s = setup(oidcEnv); t.after(s.restore);
|
|
307
|
+
const reg = await s.call("POST", "/oauth/register", { json: { client_name: "Claude", redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] } });
|
|
308
|
+
const verifier = Buffer.from(crypto.randomBytes(32)).toString("base64url");
|
|
309
|
+
const q: Record<string, string> = { response_type: "code", client_id: reg.data.client_id, redirect_uri: "https://claude.ai/api/mcp/auth_callback",
|
|
310
|
+
state: "st-1", code_challenge: await M.s256(verifier), code_challenge_method: "S256", scope: "inbox", resource: `${BASE}/mcp` };
|
|
311
|
+
const g = await s.call("GET", `/oauth/authorize?${new URLSearchParams(q)}`);
|
|
312
|
+
assert.match(g.text, /Continue with idp\.example/);
|
|
313
|
+
assert.match(g.text, /No approval password is set yet/);
|
|
314
|
+
const cookie = (g.headers.get("set-cookie") || "").split(";")[0];
|
|
315
|
+
const csrf = /name="csrf" value="([^"]+)"/.exec(g.text)![1];
|
|
316
|
+
const post = await s.call("POST", "/oauth/authorize", { form: { ...q, csrf, decision: "oidc" }, cookie, headers: { origin: BASE } });
|
|
317
|
+
assert.equal(post.status, 302, post.text);
|
|
318
|
+
const loc = new URL(post.headers.get("location")!);
|
|
319
|
+
assert.equal(loc.search.includes("st-1"), false, "the MCP state is not the identity provider's state");
|
|
320
|
+
s.setClaims(() => claims({ nonce: loc.searchParams.get("nonce")! }));
|
|
321
|
+
const back = await s.call("GET", `/oauth/authorize/oidc/callback?code=auth-code&state=${loc.searchParams.get("state")}`);
|
|
322
|
+
assert.equal(back.status, 302, back.text);
|
|
323
|
+
const client = new URL(back.headers.get("location")!);
|
|
324
|
+
assert.equal(client.origin, "https://claude.ai");
|
|
325
|
+
assert.equal(client.searchParams.get("state"), "st-1");
|
|
326
|
+
assert.ok(client.searchParams.get("code"));
|
|
327
|
+
const tok = await s.call("POST", "/oauth/token", { form: { grant_type: "authorization_code", code: client.searchParams.get("code")!, client_id: reg.data.client_id,
|
|
328
|
+
redirect_uri: q.redirect_uri, code_verifier: verifier, resource: `${BASE}/mcp` } });
|
|
329
|
+
assert.equal(tok.status, 200, tok.text);
|
|
330
|
+
assert.ok(String(tok.data.access_token).startsWith("a2amcp_"));
|
|
331
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Worker thread that owns one Miniflare and answers storage ops for the synchronous test shim in d1.ts.
|
|
2
|
+
import { workerData, parentPort } from "node:worker_threads";
|
|
3
|
+
import { Miniflare, convertV4MiniflareOptions } from "miniflare";
|
|
4
|
+
|
|
5
|
+
const { port, flag, script, pool } = workerData;
|
|
6
|
+
const d1Databases = Object.fromEntries(Array.from({ length: pool }, (_, i) => [`DB${i}`, `db-${i}-${process.pid}`]));
|
|
7
|
+
const i32 = new Int32Array(flag);
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const mf = new Miniflare(convertV4MiniflareOptions({
|
|
11
|
+
name: "a2a-storage",
|
|
12
|
+
modules: true,
|
|
13
|
+
script,
|
|
14
|
+
compatibilityDate: "2026-10-06",
|
|
15
|
+
d1Databases,
|
|
16
|
+
durableObjects: { STORE: { className: "Store", useSQLite: true } },
|
|
17
|
+
}));
|
|
18
|
+
await mf.ready;
|
|
19
|
+
port.on("message", async ({ path, body }) => {
|
|
20
|
+
let msg;
|
|
21
|
+
try {
|
|
22
|
+
const r = await mf.dispatchFetch("http://h" + path, { method: "POST", body: JSON.stringify(body) });
|
|
23
|
+
msg = await r.json();
|
|
24
|
+
} catch (e) {
|
|
25
|
+
msg = { ok: false, error: "bridge: " + (e instanceof Error ? e.message : String(e)) };
|
|
26
|
+
}
|
|
27
|
+
port.postMessage(msg);
|
|
28
|
+
Atomics.store(i32, 0, 1);
|
|
29
|
+
Atomics.notify(i32, 0);
|
|
30
|
+
});
|
|
31
|
+
port.postMessage({ ready: true });
|
|
32
|
+
Atomics.store(i32, 0, 1);
|
|
33
|
+
Atomics.notify(i32, 0);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
const error = "bridge start: " + (e instanceof Error ? e.stack || e.message : String(e));
|
|
36
|
+
parentPort?.postMessage({ error });
|
|
37
|
+
port.postMessage({ ready: false, error });
|
|
38
|
+
Atomics.store(i32, 0, 1);
|
|
39
|
+
Atomics.notify(i32, 0);
|
|
40
|
+
}
|