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
package/lib/status.mjs
CHANGED
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
// itself, so agents need no curl (some agent sandboxes flag *.dev URLs in shell commands).
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import * as C from "./config.mjs";
|
|
6
|
-
import { cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
|
|
6
|
+
import { CLI, cardUrlProblem, cfErrorCode, describeHttp, die, fingerprint, httpJson } from "./a2a.mjs";
|
|
7
7
|
import { baseUrl, owner, ownerTry } from "./commands.mjs";
|
|
8
8
|
import * as D from "./deploy.mjs";
|
|
9
9
|
import * as T from "./tunnel.mjs";
|
|
10
10
|
|
|
11
|
-
const CLI = "a2a-exposed";
|
|
12
11
|
/** Presets whose webhook usually listens only locally: wakes need the tunnel (or another reverse proxy). */
|
|
13
12
|
export const LOCAL_PRESETS = ["openclaw-wake", "openclaw-agent", "hermes"];
|
|
14
13
|
|
|
@@ -202,7 +201,8 @@ export async function collect() {
|
|
|
202
201
|
const pr = await owner("GET", "/owner/pairing");
|
|
203
202
|
if (pr && typeof pr === "object" && ["human", "agent", "off"].includes(pr.mode))
|
|
204
203
|
s.pairing = { mode: pr.mode, passwordSet: !!pr.passwordSet, passwordSetAt: pr.passwordSetAt || null, passwordSetVia: pr.passwordSetVia || null,
|
|
205
|
-
setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length
|
|
204
|
+
setupLinkExpiresAt: pr.setupLinkExpiresAt || null, pending: (pr.pending || []).length,
|
|
205
|
+
...(typeof pr.oidcIssuer === "string" && pr.oidcIssuer ? { oidcIssuer: pr.oidcIssuer } : {}) };
|
|
206
206
|
} catch { /* older Worker (no pairing yet): leave unknown */ }
|
|
207
207
|
if (s.upstream) {
|
|
208
208
|
try { s.facade = await owner("GET", "/owner/facade"); }
|
|
@@ -303,7 +303,7 @@ export async function status(o) {
|
|
|
303
303
|
if (s.zones) rows.push(["zones", s.zones.length ? s.zones.join(", ") : "none on this account"]);
|
|
304
304
|
const pg = s.pairing;
|
|
305
305
|
if (pg) rows.push(["pairing", pg.mode === "off" ? "off (token issue only)"
|
|
306
|
-
: `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
|
|
306
|
+
: `${pg.mode} approval; approval password ${pg.passwordSet ? `set${pg.passwordSetAt ? ` ${pg.passwordSetAt}` : ""}${pg.passwordSetVia ? ` (via ${pg.passwordSetVia})` : ""}` : `NOT SET${pg.setupLinkExpiresAt ? ` (a setup link is open until ${pg.setupLinkExpiresAt})` : ""}`}${pg.oidcIssuer ? `; OpenID Connect (${pg.oidcIssuer})` : ""}${pg.pending ? `; ${pg.pending} pending request(s): ${CLI} pair list` : ""}`]);
|
|
307
307
|
}
|
|
308
308
|
rows.push(["next step", next.text]);
|
|
309
309
|
for (const a of next.also || []) rows.push(["also", a]);
|
package/lib/tunnel.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import crypto from "node:crypto";
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
12
|
import * as C from "./config.mjs";
|
|
13
|
-
import { CliError, die, fingerprint, httpJson } from "./a2a.mjs";
|
|
13
|
+
import { CLI, CliError, die, fingerprint, httpJson } from "./a2a.mjs";
|
|
14
14
|
import { owner } from "./commands.mjs";
|
|
15
15
|
import * as D from "./deploy.mjs";
|
|
16
16
|
|
|
@@ -63,7 +63,7 @@ export function noZoneHelp(zones) {
|
|
|
63
63
|
: "this Cloudflare account has no domain (zone).",
|
|
64
64
|
"The wake tunnel needs a zone anywhere on the account for its hostname and Access app (whether the inbox is on workers.dev or a custom hostname).",
|
|
65
65
|
"Without one, use scheduled polling: the inbox works as it is (setup skill: \"Agents without inbound webhooks: polling\").",
|
|
66
|
-
|
|
66
|
+
`To use the tunnel later, add a domain to the account and run \`${CLI} tunnel create\` (no redeploy needed).`,
|
|
67
67
|
].join("\n");
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -215,9 +215,9 @@ export function listZones(dir) {
|
|
|
215
215
|
|
|
216
216
|
// ------------------------------------------------------------------ commands
|
|
217
217
|
function needDeployment(o) {
|
|
218
|
-
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(
|
|
218
|
+
if (!C.get("A2A_D1_ID") || !C.get("A2A_WORKER_NAME")) die(`no saved deployment; run \`${CLI} init\` first`);
|
|
219
219
|
const dir = D.workerDir(o);
|
|
220
|
-
if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run
|
|
220
|
+
if (!fs.existsSync(dir)) die(`worker project not found at ${dir}; run \`${CLI} deploy\` once`);
|
|
221
221
|
return dir;
|
|
222
222
|
}
|
|
223
223
|
|
|
@@ -293,12 +293,12 @@ export async function create(o) {
|
|
|
293
293
|
console.log(url);
|
|
294
294
|
console.error(`\ntunnel ready: wakes go to ${url} -> ${origin}${wpath} on the agent's machine`);
|
|
295
295
|
console.error(connectorInstructions(tokenFile, o["show-token"] ? token : ""));
|
|
296
|
-
console.error(`\nThen (Worker secrets apply within ~15 s):
|
|
296
|
+
console.error(`\nThen (Worker secrets apply within ~15 s): ${CLI} tunnel status and ${CLI} wake test`);
|
|
297
297
|
if (!process.env.WAKE_WEBHOOK_KEY && !process.env.WAKE_HMAC_SECRET && preset !== "generic")
|
|
298
298
|
console.error(`note: no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET in the environment; keep the ${preset} webhook's own auth too (export it and run \`wake set\`)`);
|
|
299
299
|
} catch (e) {
|
|
300
300
|
console.error(`error: ${e.message}\n==> rolling back what was created`);
|
|
301
|
-
await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run
|
|
301
|
+
await remove(o, { quiet: true }).catch((r) => console.error(`rollback incomplete: ${r.message}; re-run \`${CLI} tunnel rm\``));
|
|
302
302
|
throw e;
|
|
303
303
|
}
|
|
304
304
|
}
|
|
@@ -335,10 +335,10 @@ export const tunnelComplete = () =>
|
|
|
335
335
|
* tunnel must be removed first. */
|
|
336
336
|
async function resume(dir, o) {
|
|
337
337
|
if (!tunnelComplete())
|
|
338
|
-
die(
|
|
338
|
+
die(`a previous \`tunnel create\` did not finish (some ids are saved, see \`${CLI} status\`); run \`${CLI} tunnel rm\`, then \`${CLI} tunnel create\` again`);
|
|
339
339
|
const host = C.get("A2A_TUNNEL_HOSTNAME");
|
|
340
340
|
if (o["tunnel-hostname"] && o["tunnel-hostname"].toLowerCase() !== host)
|
|
341
|
-
die(`a tunnel already exists on ${host}; to use another hostname run
|
|
341
|
+
die(`a tunnel already exists on ${host}; to use another hostname run \`${CLI} tunnel rm\` first`);
|
|
342
342
|
if (!tokenFileOk()) {
|
|
343
343
|
step(`the connector token file ${C.get("A2A_TUNNEL_TOKEN_FILE")} is missing or empty: downloading the tunnel token again`);
|
|
344
344
|
fetchTokenFile(dir, C.get("A2A_TUNNEL_ID"));
|
|
@@ -356,11 +356,11 @@ async function resume(dir, o) {
|
|
|
356
356
|
}
|
|
357
357
|
const preset = C.get("WAKE_PRESET", "generic");
|
|
358
358
|
if (p && !p.hasKey && !p.hasHmacSecret && !Object.keys(agentAuth).length && preset !== "generic")
|
|
359
|
-
console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run
|
|
359
|
+
console.error(`warning: the Worker has no WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET, so the ${preset} webhook will reject wakes; export it and run \`${CLI} tunnel create\` again`);
|
|
360
360
|
console.log(url);
|
|
361
361
|
console.error(`tunnel already set up: wakes go to ${url} -> ${C.get("A2A_TUNNEL_ORIGIN")}${C.get("A2A_TUNNEL_PATH")} (nothing new created; to change it: tunnel rm, then tunnel create)`);
|
|
362
362
|
console.error(connectorInstructions(C.get("A2A_TUNNEL_TOKEN_FILE")));
|
|
363
|
-
console.error(
|
|
363
|
+
console.error(`Then: ${CLI} tunnel status and ${CLI} wake test`);
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
function putSecrets(dir, worker, map) {
|
|
@@ -370,7 +370,7 @@ function putSecrets(dir, worker, map) {
|
|
|
370
370
|
export async function status(o) {
|
|
371
371
|
const dir = needDeployment(o);
|
|
372
372
|
const host = C.get("A2A_TUNNEL_HOSTNAME");
|
|
373
|
-
if (!host) return console.log(
|
|
373
|
+
if (!host) return console.log(`no tunnel configured (${CLI} tunnel create)`);
|
|
374
374
|
const info = { hostname: host, wakeUrl: wakeUrl(host, C.get("A2A_TUNNEL_PATH") || "/"), origin: C.get("A2A_TUNNEL_ORIGIN") };
|
|
375
375
|
const tid = C.get("A2A_TUNNEL_ID");
|
|
376
376
|
if (tid) {
|
package/lib/update.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export function newer(a, b) {
|
|
|
31
31
|
/** The notice for `current`, given the cache contents (or "" when up to date / unknown). */
|
|
32
32
|
export function notice(current, cache) {
|
|
33
33
|
if (!cache || !cache.latest || !newer(cache.latest, current)) return "";
|
|
34
|
-
return `a2a-exposed ${cache.latest} is available (you have ${current}):
|
|
34
|
+
return `a2a-exposed ${cache.latest} is available (you have ${current}): run npx -y a2a-exposed@latest deploy (updates the Worker; applies new D1 migrations). With a global install, npm i -g a2a-exposed@latest first. Silence: A2A_NO_UPDATE_CHECK=1`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export const due = (cache, now = Date.now()) => !cache || !(now - (cache.checkedAt || 0) < CHECK_INTERVAL_MS);
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// a2a-exposed Worker configuration. Everything deployment-specific comes from environment
|
|
2
|
-
// variables at deploy time (set by `npx a2a-exposed init|deploy`, or export them yourself;
|
|
2
|
+
// variables at deploy time (set by `npx -y a2a-exposed@latest init|deploy`, or export them yourself;
|
|
3
3
|
// see deploy.env.example). Nothing account- or owner-specific is committed here.
|
|
4
|
-
import { bindings, defineConfig, triggers } from "cf/config";
|
|
5
|
-
import * as
|
|
4
|
+
import { bindings, defineConfig, exports, triggers } from "cf/config";
|
|
5
|
+
import * as selfHost from "./src/index.ts" with { type: "cf-worker" };
|
|
6
|
+
import * as hostedEntry from "./src/hosted.ts" with { type: "cf-worker" };
|
|
6
7
|
|
|
7
8
|
const e: Record<string, string | undefined> = (globalThis as any).process?.env ?? {};
|
|
8
9
|
const v = (k: string) => (e[k] ?? "").trim();
|
|
@@ -32,36 +33,78 @@ const plain: Record<string, string> = {
|
|
|
32
33
|
WAKE_KEY_HEADER: v("WAKE_KEY_HEADER"), // generic preset (default "authorization")
|
|
33
34
|
WAKE_KEY_PREFIX: e.WAKE_KEY_PREFIX ?? "", // generic preset; unset = "Bearer "
|
|
34
35
|
WAKE_BODY_TEMPLATE: v("WAKE_BODY_TEMPLATE"), // generic preset JSON template
|
|
35
|
-
WAKE_CLI_COMMAND: v("WAKE_CLI_COMMAND"), // how the woken agent runs the CLI (default "npx a2a-exposed")
|
|
36
|
+
WAKE_CLI_COMMAND: v("WAKE_CLI_COMMAND"), // how the woken agent runs the CLI (default "npx -y a2a-exposed@latest")
|
|
36
37
|
WAKE_DEBOUNCE_SECONDS: v("WAKE_DEBOUNCE_SECONDS"),
|
|
37
38
|
WAKE_MAX_PER_HOUR: v("WAKE_MAX_PER_HOUR"),
|
|
38
39
|
MAX_BODY: v("A2A_MAX_BODY"),
|
|
39
40
|
RATE_PER_MIN: v("A2A_RATE_PER_MIN"),
|
|
40
41
|
RETIRED_HOSTNAMES: v("A2A_RETIRED_HOSTNAMES"), // custom domains this agent moved away from (see movedResponse)
|
|
41
42
|
PAIRING_APPROVAL: v("PAIRING_APPROVAL") || "human", // device-flow pairing: human (approval password on /device) | agent | off
|
|
43
|
+
MCP: v("A2A_MCP"), // remote MCP server at /mcp (connector for Claude etc.): on unless "off"; never in proxy mode
|
|
42
44
|
PBKDF2_ITERATIONS: v("A2A_PBKDF2_ITERATIONS"), // approval password hashing; default 100000 (the Workers maximum), at least 50000
|
|
43
45
|
// proxy / expose mode: forward A2A to an agent that already speaks it, published through a Tunnel hostname behind
|
|
44
46
|
// Access (never a Tailnet / LAN URL: the Worker can't reach those, and they never appear in the public card)
|
|
45
47
|
UPSTREAM_URL: v("A2A_UPSTREAM_URL"),
|
|
46
48
|
UPSTREAM_CARD_URL: v("A2A_UPSTREAM_CARD_URL"), // default <upstream origin>/.well-known/agent-card.json
|
|
49
|
+
// Hosted multi-tenant gates. All empty by default, and empty is omitted, so a self-host deploy is unchanged.
|
|
50
|
+
// TENANCY=host switches the entrypoint to src/hosted.ts and adds TENANT_DO + TENANT_DIRECTORY below.
|
|
51
|
+
// QUOTAS, USAGE_SINK, WAKE_TARGET_POLICY, SIGNUP_URL and BRANDING are parsed and reserved.
|
|
52
|
+
// APPROVAL_OIDC_* turn on OpenID Connect approval when issuer, client id, secret and allowlist are all set.
|
|
53
|
+
TENANCY: v("TENANCY"),
|
|
54
|
+
TENANT_DOMAIN: v("TENANT_DOMAIN"),
|
|
55
|
+
DATA_REGION: v("DATA_REGION"), // eu | fedramp; unset = Cloudflare's default placement
|
|
56
|
+
QUOTAS: v("QUOTAS"),
|
|
57
|
+
USAGE_SINK: v("USAGE_SINK"),
|
|
58
|
+
WAKE_TARGET_POLICY: v("WAKE_TARGET_POLICY"),
|
|
59
|
+
SIGNUP_URL: v("SIGNUP_URL"),
|
|
60
|
+
BRANDING: v("BRANDING"),
|
|
61
|
+
APPROVAL_OIDC_ISSUER: v("APPROVAL_OIDC_ISSUER"),
|
|
62
|
+
APPROVAL_OIDC_CLIENT_ID: v("APPROVAL_OIDC_CLIENT_ID"),
|
|
63
|
+
APPROVAL_OIDC_ALLOWED_SUBJECTS: v("APPROVAL_OIDC_ALLOWED_SUBJECTS"),
|
|
64
|
+
APPROVAL_METHODS: v("APPROVAL_METHODS"),
|
|
47
65
|
};
|
|
66
|
+
const hosted = v("TENANCY") === "host";
|
|
67
|
+
// Daily R2 snapshots. Unset: no binding and no extra cron, so a self-host deploy stays the single-tenant Worker.
|
|
68
|
+
const backups = v("A2A_BACKUP_BUCKET") === "1";
|
|
48
69
|
const envBindings: Record<string, any> = {
|
|
49
70
|
DB: bindings.d1(v("A2A_D1_ID") ? { name: v("A2A_D1_NAME") || name, id: v("A2A_D1_ID") } : { name: v("A2A_D1_NAME") || name }),
|
|
50
71
|
};
|
|
72
|
+
// Per-tenant SQLite and the name directory exist only on a hosted deploy. A self-host build does not export the
|
|
73
|
+
// Durable Object class and does not declare these bindings, so `cf deploy` stays the single-tenant Worker on D1.
|
|
74
|
+
if (hosted) {
|
|
75
|
+
envBindings.TENANT_DO = bindings.durableObject({ worker: name, exportName: "TenantStore" });
|
|
76
|
+
envBindings.TENANT_DIRECTORY = bindings.kv();
|
|
77
|
+
}
|
|
78
|
+
if (backups) {
|
|
79
|
+
const region = v("DATA_REGION");
|
|
80
|
+
const bucketName = v("A2A_BACKUP_BUCKET_NAME");
|
|
81
|
+
envBindings.BACKUP_BUCKET = bindings.r2({
|
|
82
|
+
...(bucketName ? { name: bucketName } : {}),
|
|
83
|
+
...(region === "eu" || region === "fedramp" ? { jurisdiction: region } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const crons = [
|
|
87
|
+
...(v("A2A_ENABLE_CRON") === "1" ? [triggers.scheduled({ schedule: "* * * * *" })] : []),
|
|
88
|
+
...(backups ? [triggers.scheduled({ schedule: "0 3 * * *" })] : []),
|
|
89
|
+
];
|
|
51
90
|
for (const [k, val] of Object.entries(plain)) if (val !== "" || (k === "WAKE_KEY_PREFIX" && "WAKE_KEY_PREFIX" in e)) envBindings[k] = bindings.text(val);
|
|
52
91
|
|
|
53
92
|
// Secrets are uploaded separately (never in this file): OWNER_TOKEN, WAKE_WEBHOOK_URL, WAKE_WEBHOOK_KEY, WAKE_HMAC_SECRET,
|
|
54
93
|
// and in proxy mode UPSTREAM_TOKEN, UPSTREAM_ACCESS_CLIENT_ID, UPSTREAM_ACCESS_CLIENT_SECRET.
|
|
55
94
|
// They are declared only in development mode (`cf dev`), so local dev reads them from .dev.vars while
|
|
56
95
|
// production deploys never fail because an optional wake secret is unset.
|
|
57
|
-
const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"
|
|
96
|
+
const SECRETS = ["OWNER_TOKEN", "WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET",
|
|
97
|
+
// Declared for `cf dev` only when a deploy asks for them, so a self-host .dev.vars without these still starts.
|
|
98
|
+
...(v("TENANCY") === "host" ? ["TENANT_SECRETS_KEY"] : []),
|
|
99
|
+
...(v("TENANCY") === "host" || v("APPROVAL_OIDC_ISSUER") ? ["APPROVAL_OIDC_CLIENT_SECRET"] : [])];
|
|
58
100
|
|
|
59
101
|
export default defineConfig((ctx) => ({
|
|
60
102
|
...(v("CLOUDFLARE_ACCOUNT_ID") ? { accountId: v("CLOUDFLARE_ACCOUNT_ID") } : {}),
|
|
61
103
|
worker: {
|
|
62
104
|
name,
|
|
63
105
|
compatibilityDate: "2026-10-06",
|
|
64
|
-
entrypoint,
|
|
106
|
+
entrypoint: hosted ? hostedEntry : selfHost,
|
|
107
|
+
...(hosted ? { exports: { TenantStore: exports.durableObject({ storage: "sqlite" }) } } : {}),
|
|
65
108
|
...(hostname ? { domains: [hostname] } : {}),
|
|
66
109
|
workersDev,
|
|
67
110
|
// Optional persisted Workers Logs (A2A_WORKERS_LOGS=1, `init|deploy --workers-logs`): the JSON event lines plus
|
|
@@ -70,6 +113,7 @@ export default defineConfig((ctx) => ({
|
|
|
70
113
|
env: ctx.mode === "development" ? { ...envBindings, ...Object.fromEntries(SECRETS.map((k) => [k, bindings.secret()])) } : envBindings,
|
|
71
114
|
// Optional cron flush of debounced wakes (the Worker also flushes opportunistically on every request).
|
|
72
115
|
// Requires a workers.dev subdomain on the account (always the case for workers.dev deployments).
|
|
73
|
-
|
|
116
|
+
// A2A_BACKUP_BUCKET=1 adds a second trigger, 03:00 UTC, which writes SQL snapshots and does not flush wakes.
|
|
117
|
+
...(crons.length ? { triggers: crons } : {}),
|
|
74
118
|
},
|
|
75
119
|
}));
|
|
@@ -18,7 +18,7 @@ WAKE_PRESET=generic
|
|
|
18
18
|
# WAKE_KEY_HEADER=authorization
|
|
19
19
|
# WAKE_KEY_PREFIX=Bearer
|
|
20
20
|
# WAKE_BODY_TEMPLATE={"text":"{{summary}}"}
|
|
21
|
-
# Command shown in wake hints (CLI: --cli-command; default "npx a2a-exposed").
|
|
21
|
+
# Command shown in wake hints (CLI: --cli-command; default "npx -y a2a-exposed@latest"). For a source checkout:
|
|
22
22
|
# WAKE_CLI_COMMAND=node /path/to/a2a-exposed/cli/bin/a2a-exposed.mjs
|
|
23
23
|
# WAKE_DEBOUNCE_SECONDS=10
|
|
24
24
|
# WAKE_MAX_PER_HOUR=0
|
|
@@ -31,3 +31,30 @@ WAKE_PRESET=generic
|
|
|
31
31
|
# Cloudflare Access WAKE_ACCESS_CLIENT_ID / WAKE_ACCESS_CLIENT_SECRET; in proxy mode UPSTREAM_TOKEN,
|
|
32
32
|
# UPSTREAM_ACCESS_CLIENT_ID / UPSTREAM_ACCESS_CLIENT_SECRET) go in a separate chmod-600
|
|
33
33
|
# file passed with `cf deploy --secrets-file <file>`; never commit them.
|
|
34
|
+
#
|
|
35
|
+
# Optional multi-tenant gates. Leave them unset for a self-hosted inbox (`npx -y a2a-exposed@latest init`
|
|
36
|
+
# never sets them). Unset means today's single-tenant Worker on D1, and the build does not export a Durable Object.
|
|
37
|
+
# TENANCY=host switches the entry to the multi-tenant router and adds the TENANT_DO and TENANT_DIRECTORY bindings.
|
|
38
|
+
# The other values are reserved (parsed, not enforced yet). Do not set TENANCY for a self-hosted inbox.
|
|
39
|
+
# TENANCY=
|
|
40
|
+
# TENANT_DOMAIN=example.com
|
|
41
|
+
# DATA_REGION=eu
|
|
42
|
+
# Written onto a directory entry the first time that entry has no region. After that the stored region places the tenant.
|
|
43
|
+
# QUOTAS=on
|
|
44
|
+
# USAGE_SINK=d1
|
|
45
|
+
# WAKE_TARGET_POLICY=public-https
|
|
46
|
+
# SIGNUP_URL=https://example.com
|
|
47
|
+
# BRANDING=hosted
|
|
48
|
+
# OpenID Connect approval (optional). All four of issuer, client id, allowlist and the client secret
|
|
49
|
+
# must be set or the pages stay password-only. APPROVAL_METHODS=oidc hides the password once that
|
|
50
|
+
# config is complete; APPROVAL_METHODS=password keeps the password even if an issuer is set.
|
|
51
|
+
# APPROVAL_OIDC_ISSUER=https://example.com
|
|
52
|
+
# APPROVAL_OIDC_CLIENT_ID=
|
|
53
|
+
# APPROVAL_OIDC_ALLOWED_SUBJECTS=
|
|
54
|
+
# APPROVAL_METHODS=password
|
|
55
|
+
# Secrets (same secrets file, never here): APPROVAL_OIDC_CLIENT_SECRET, and for a hosted deploy TENANT_SECRETS_KEY.
|
|
56
|
+
# Hosted approval uses the tenant's pushed approval object, not these Worker settings.
|
|
57
|
+
# Daily SQL snapshots to R2. Unset: no BACKUP_BUCKET binding and no extra cron. The minute wake cron stays A2A_ENABLE_CRON.
|
|
58
|
+
# A2A_BACKUP_BUCKET=1
|
|
59
|
+
# A2A_BACKUP_BUCKET_NAME=a2a-exposed-backups
|
|
60
|
+
# With DATA_REGION=eu or fedramp the bucket is created in that jurisdiction. Objects are kept 30 days.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Remote MCP server (/mcp): OAuth 2.1 clients (dynamic registration), one-time authorization codes, and the grants
|
|
2
|
+
-- (owner-approved, inbox-scoped tokens) that `token list` shows and `token revoke` ends. Peer tokens stay in `peers`
|
|
3
|
+
-- and are never accepted on /mcp; MCP tokens are never accepted on the A2A endpoint or /owner/*.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT,
|
|
7
|
+
redirect_uris_json TEXT NOT NULL,
|
|
8
|
+
created_ms INTEGER NOT NULL,
|
|
9
|
+
ip TEXT
|
|
10
|
+
);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
12
|
+
code_hash TEXT PRIMARY KEY,
|
|
13
|
+
client_id TEXT NOT NULL,
|
|
14
|
+
redirect_uri TEXT NOT NULL,
|
|
15
|
+
code_challenge TEXT NOT NULL,
|
|
16
|
+
scope TEXT NOT NULL,
|
|
17
|
+
resource TEXT NOT NULL,
|
|
18
|
+
created_ms INTEGER NOT NULL,
|
|
19
|
+
expires_ms INTEGER NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS mcp_grants (
|
|
22
|
+
label TEXT PRIMARY KEY,
|
|
23
|
+
client_id TEXT NOT NULL,
|
|
24
|
+
client_name TEXT,
|
|
25
|
+
redirect_host TEXT,
|
|
26
|
+
scope TEXT NOT NULL,
|
|
27
|
+
access_hash TEXT UNIQUE,
|
|
28
|
+
access_expires_ms INTEGER,
|
|
29
|
+
refresh_hash TEXT UNIQUE,
|
|
30
|
+
refresh_expires_ms INTEGER,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
last_used_at TEXT,
|
|
33
|
+
revoked_at TEXT
|
|
34
|
+
);
|
|
35
|
+
-- Peers this inbox sends to from the Worker (MCP `send` / `poll_outbound`), uploaded by `peers sync`. The token is
|
|
36
|
+
-- AES-GCM ciphertext under a key derived from the Worker's OWNER_TOKEN secret, so a D1 export alone doesn't reveal it.
|
|
37
|
+
CREATE TABLE IF NOT EXISTS outbound_peers (
|
|
38
|
+
alias TEXT PRIMARY KEY,
|
|
39
|
+
url TEXT NOT NULL,
|
|
40
|
+
token_enc TEXT,
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
updated_at TEXT NOT NULL
|
|
43
|
+
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- MCP authorization (2026-07-28): OAuth Client ID Metadata Documents. A client_id that is an https URL names a JSON
|
|
2
|
+
-- document the Worker fetches (SSRF-guarded, 5 KB, 5 s) and validates; valid documents are cached here for their
|
|
3
|
+
-- Cache-Control max-age (60 s - 24 h, default 1 h). Errors and invalid documents are never cached.
|
|
4
|
+
CREATE TABLE IF NOT EXISTS oauth_client_metadata (
|
|
5
|
+
client_id TEXT PRIMARY KEY,
|
|
6
|
+
client_name TEXT,
|
|
7
|
+
redirect_uris_json TEXT NOT NULL,
|
|
8
|
+
fetched_ms INTEGER NOT NULL,
|
|
9
|
+
expires_ms INTEGER NOT NULL
|
|
10
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Short-lived OpenID Connect sign-in (authorization code + PKCE). The callback deletes the row;
|
|
2
|
+
-- expired rows are removed when a new one is stored and by the pairing cleanup.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS oidc_txns (
|
|
4
|
+
state TEXT PRIMARY KEY,
|
|
5
|
+
nonce TEXT NOT NULL,
|
|
6
|
+
verifier TEXT NOT NULL,
|
|
7
|
+
kind TEXT NOT NULL,
|
|
8
|
+
csrf TEXT NOT NULL,
|
|
9
|
+
user_code TEXT,
|
|
10
|
+
payload_json TEXT NOT NULL,
|
|
11
|
+
expires_ms INTEGER NOT NULL
|
|
12
|
+
);
|
package/worker/package.json
CHANGED
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "cf build",
|
|
9
9
|
"deploy": "cf deploy",
|
|
10
|
-
"test": "node --test test/*.test.ts",
|
|
10
|
+
"test": "node --import ./test/register-hooks.mjs --test test/*.test.ts",
|
|
11
11
|
"typecheck": "cf workers types && tsc"
|
|
12
12
|
},
|
|
13
|
-
"engines": {
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.18"
|
|
15
|
+
},
|
|
14
16
|
"devDependencies": {
|
|
15
17
|
"@cloudflare/vite-plugin": "beta",
|
|
16
18
|
"cf": "^1.0.0-beta.13",
|
|
19
|
+
"esbuild": "^0.28.1",
|
|
20
|
+
"miniflare": "^5.20261006.0-alpha",
|
|
17
21
|
"typescript": "^7.0.2",
|
|
18
22
|
"vite": "^8.3.0"
|
|
19
23
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Optional OpenID Connect approval for /device and /oauth/authorize.
|
|
2
|
+
// Active only when the issuer (https), client id, client secret and at least one allowed subject are all set,
|
|
3
|
+
// and the method list includes oidc (or APPROVAL_METHODS was left unset). Password stays the default.
|
|
4
|
+
// The client secret is read from TenantContext and is never logged or placed on the gate object.
|
|
5
|
+
import * as A from "./a2a.ts";
|
|
6
|
+
import type { ApprovalOidcGate, TenantContext } from "./tenancy.ts";
|
|
7
|
+
|
|
8
|
+
export const TXN_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
const ID_TOKEN_MAX = 8192;
|
|
10
|
+
const SKEW_S = 60;
|
|
11
|
+
const FETCH_MS = 5000;
|
|
12
|
+
|
|
13
|
+
export type OidcKind = "device" | "mcp";
|
|
14
|
+
|
|
15
|
+
export interface ApprovalPolicy {
|
|
16
|
+
oidc: boolean;
|
|
17
|
+
password: boolean;
|
|
18
|
+
/** Button label: configured label, otherwise the issuer host. */
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function issuerHost(issuer: string): string {
|
|
23
|
+
try { return new URL(issuer).host; } catch { return ""; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function issuerBase(issuer: string): string {
|
|
27
|
+
return issuer.replace(/\/+$/, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What the approval pages offer.
|
|
32
|
+
* OIDC is on when it is fully configured and methods include oidc, or when methods were not specified.
|
|
33
|
+
* Password stays on unless methods were specified as oidc only AND the OIDC config is complete
|
|
34
|
+
* (an incomplete `APPROVAL_METHODS=oidc` must not lock the operator out).
|
|
35
|
+
* `APPROVAL_METHODS=password` keeps the password and does not turn OIDC on.
|
|
36
|
+
*/
|
|
37
|
+
export function approvalPolicy(gate: ApprovalOidcGate): ApprovalPolicy {
|
|
38
|
+
const configured = !!(gate.issuer && gate.clientId && gate.hasClientSecret && gate.allowedSubjects.length);
|
|
39
|
+
const oidc = configured && (gate.methods.includes("oidc") || !gate.methodsSpecified);
|
|
40
|
+
const oidcOnly = gate.methodsSpecified && gate.methods.includes("oidc") && !gate.methods.includes("password");
|
|
41
|
+
return { oidc, password: !(oidcOnly && configured), label: gate.label || issuerHost(gate.issuer) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type StartResult = { ok: true; location: string } | { ok: false; error: string; reason: string };
|
|
45
|
+
export type FinishResult =
|
|
46
|
+
| { ok: true; userCode: string | null; payload: Record<string, unknown>; subject: string }
|
|
47
|
+
| { ok: false; error: string; reason: string; userCode: string | null };
|
|
48
|
+
|
|
49
|
+
const FAIL = "Sign-in could not be verified. Nothing was approved.";
|
|
50
|
+
const UNREACHABLE = "The identity provider could not be reached. Nothing was approved.";
|
|
51
|
+
|
|
52
|
+
function b64url(bytes: Uint8Array): string {
|
|
53
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function b64urlDecode(s: string): Uint8Array {
|
|
57
|
+
if (!/^[A-Za-z0-9_-]+$/.test(s) || s.length > ID_TOKEN_MAX) throw new Error("b64");
|
|
58
|
+
const pad = "=".repeat((4 - (s.length % 4)) % 4);
|
|
59
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
60
|
+
const out = new Uint8Array(bin.length);
|
|
61
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function pkce(): Promise<{ verifier: string; challenge: string }> {
|
|
66
|
+
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
67
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
68
|
+
return { verifier, challenge: b64url(new Uint8Array(digest)) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function httpsEndpoint(v: unknown): string {
|
|
72
|
+
if (typeof v !== "string" || !v) throw new Error("endpoint");
|
|
73
|
+
const u = new URL(v);
|
|
74
|
+
if (u.protocol !== "https:" || u.username || u.password) throw new Error("endpoint");
|
|
75
|
+
return u.toString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readJson(res: Response, max: number): Promise<any> {
|
|
79
|
+
const len = Number(res.headers.get("content-length") || 0);
|
|
80
|
+
if (len > max) throw new Error("large");
|
|
81
|
+
const text = await res.text();
|
|
82
|
+
if (text.length > max) throw new Error("large");
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function getJson(url: string): Promise<any> {
|
|
87
|
+
const res = await fetch(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(FETCH_MS) });
|
|
88
|
+
if (!res.ok) throw new Error("http");
|
|
89
|
+
return readJson(res, 65536);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Discovery {
|
|
93
|
+
authorization_endpoint: string;
|
|
94
|
+
token_endpoint: string;
|
|
95
|
+
jwks_uri: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function discover(issuer: string): Promise<Discovery> {
|
|
99
|
+
const doc = await getJson(`${issuerBase(issuer)}/.well-known/openid-configuration`);
|
|
100
|
+
if (issuerBase(String(doc?.issuer || "")) !== issuerBase(issuer)) throw new Error("issuer");
|
|
101
|
+
return {
|
|
102
|
+
authorization_endpoint: httpsEndpoint(doc.authorization_endpoint),
|
|
103
|
+
token_endpoint: httpsEndpoint(doc.token_endpoint),
|
|
104
|
+
jwks_uri: httpsEndpoint(doc.jwks_uri),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Begin a sign-in. `userCode` is stored with the state and is not put on the identity provider's URL. */
|
|
109
|
+
export async function startOidc(ctx: TenantContext, o: {
|
|
110
|
+
kind: OidcKind; redirectUri: string; csrf: string; userCode: string | null; payload: Record<string, unknown>;
|
|
111
|
+
}): Promise<StartResult> {
|
|
112
|
+
const gate = ctx.gates.approvalOidc;
|
|
113
|
+
if (!approvalPolicy(gate).oidc || !ctx.approvalClientSecret()) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config" };
|
|
114
|
+
let disc: Discovery;
|
|
115
|
+
try { disc = await discover(gate.issuer); }
|
|
116
|
+
catch { return { ok: false, error: UNREACHABLE, reason: "discovery" }; }
|
|
117
|
+
const { verifier, challenge } = await pkce();
|
|
118
|
+
const state = b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
119
|
+
const nonce = b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
120
|
+
const payload = JSON.stringify(o.payload);
|
|
121
|
+
if (payload.length > 16384) return { ok: false, error: "This approval could not be started.", reason: "payload" };
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
await ctx.DB.prepare("DELETE FROM oidc_txns WHERE expires_ms < ?").bind(now).run();
|
|
124
|
+
await ctx.DB.prepare("INSERT INTO oidc_txns (state, nonce, verifier, kind, csrf, user_code, payload_json, expires_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
125
|
+
.bind(state, nonce, verifier, o.kind, o.csrf, o.userCode, payload, now + TXN_TTL_MS).run();
|
|
126
|
+
const u = new URL(disc.authorization_endpoint);
|
|
127
|
+
u.searchParams.set("response_type", "code");
|
|
128
|
+
u.searchParams.set("client_id", gate.clientId);
|
|
129
|
+
u.searchParams.set("redirect_uri", o.redirectUri);
|
|
130
|
+
// `email` is required for an email allowlist: providers omit `email` and `email_verified` unless this scope is requested.
|
|
131
|
+
u.searchParams.set("scope", "openid email");
|
|
132
|
+
u.searchParams.set("state", state);
|
|
133
|
+
u.searchParams.set("nonce", nonce);
|
|
134
|
+
u.searchParams.set("code_challenge", challenge);
|
|
135
|
+
u.searchParams.set("code_challenge_method", "S256");
|
|
136
|
+
return { ok: true, location: u.toString() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function onAllowlist(value: unknown, subjects: string[]): boolean {
|
|
140
|
+
if (typeof value !== "string") return false;
|
|
141
|
+
for (const want of subjects) if (A.timingSafeEqualStr(value, want)) return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* OIDC ID Token validation: `aud` must contain this client. When `aud` has more than one value, `azp` is required
|
|
147
|
+
* and must be this client id. A present `azp` must match even when there is only one audience.
|
|
148
|
+
*/
|
|
149
|
+
function audienceOk(aud: unknown, azp: unknown, clientId: string): boolean {
|
|
150
|
+
const raw = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : [];
|
|
151
|
+
if (!onAllowlist(clientId, raw.filter((a): a is string => typeof a === "string"))) return false;
|
|
152
|
+
const multi = raw.length > 1;
|
|
153
|
+
if (!multi && azp === undefined) return true;
|
|
154
|
+
return typeof azp === "string" && A.timingSafeEqualStr(azp, clientId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Prefer `sub`. An `email` match is a separate path and is ignored unless `email_verified` is boolean true,
|
|
159
|
+
* so an unverified address cannot approve.
|
|
160
|
+
*/
|
|
161
|
+
function subjectAllowed(claims: { sub?: unknown; email?: unknown; email_verified?: unknown }, subjects: string[]): boolean {
|
|
162
|
+
if (onAllowlist(claims.sub, subjects)) return true;
|
|
163
|
+
if (claims.email_verified !== true) return false;
|
|
164
|
+
return onAllowlist(claims.email, subjects);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function verifyIdToken(token: string, jwks: { keys?: any[] }, expect: { iss: string; aud: string; nonce: string; subjects: string[] }): Promise<{ subject: string } | { reason: string }> {
|
|
168
|
+
if (typeof token !== "string" || token.length < 20 || token.length > ID_TOKEN_MAX) return { reason: "token" };
|
|
169
|
+
const parts = token.split(".");
|
|
170
|
+
if (parts.length !== 3) return { reason: "token" };
|
|
171
|
+
let header: any;
|
|
172
|
+
try { header = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[0]))); }
|
|
173
|
+
catch { return { reason: "token" }; }
|
|
174
|
+
// RS256 only. `none` and HMAC (which would use the client secret as a MAC key) are rejected before verification.
|
|
175
|
+
if (header.alg !== "RS256") return { reason: "alg" };
|
|
176
|
+
const keys: any[] = Array.isArray(jwks.keys) ? jwks.keys : [];
|
|
177
|
+
const jwk = header.kid ? keys.find((k) => k && k.kid === header.kid) : keys.length === 1 ? keys[0] : null;
|
|
178
|
+
if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e || (jwk.alg && jwk.alg !== "RS256")) return { reason: "key" };
|
|
179
|
+
let key: CryptoKey;
|
|
180
|
+
try {
|
|
181
|
+
key = await crypto.subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true },
|
|
182
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
|
|
183
|
+
} catch { return { reason: "key" }; }
|
|
184
|
+
let sig: Uint8Array;
|
|
185
|
+
try { sig = b64urlDecode(parts[2]); } catch { return { reason: "sig" }; }
|
|
186
|
+
const ok = await crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, key, sig, new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
|
|
187
|
+
if (!ok) return { reason: "sig" };
|
|
188
|
+
let claims: any;
|
|
189
|
+
try { claims = JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1]))); }
|
|
190
|
+
catch { return { reason: "token" }; }
|
|
191
|
+
if (issuerBase(String(claims.iss || "")) !== issuerBase(expect.iss)) return { reason: "iss" };
|
|
192
|
+
if (!audienceOk(claims.aud, claims.azp, expect.aud)) return { reason: "aud" };
|
|
193
|
+
if (typeof claims.nonce !== "string" || !A.timingSafeEqualStr(claims.nonce, expect.nonce)) return { reason: "nonce" };
|
|
194
|
+
const nowS = Date.now() / 1000;
|
|
195
|
+
if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp + SKEW_S < nowS) return { reason: "exp" };
|
|
196
|
+
if (typeof claims.nbf === "number" && claims.nbf - SKEW_S > nowS) return { reason: "nbf" };
|
|
197
|
+
if (!subjectAllowed(claims, expect.subjects)) return { reason: "sub" };
|
|
198
|
+
return { subject: typeof claims.sub === "string" ? claims.sub : String(claims.email) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Complete a sign-in. The state row is deleted on every callback, including failure, so it cannot be replayed.
|
|
203
|
+
* The CSRF cookie is not read here: SameSite=Strict is dropped on the cross-site return from the identity
|
|
204
|
+
* provider. The start POST already checked that cookie and stored it with this state.
|
|
205
|
+
*/
|
|
206
|
+
export async function finishOidc(ctx: TenantContext, o: {
|
|
207
|
+
kind: OidcKind; redirectUri: string; state: string; code: string | null; error: string | null;
|
|
208
|
+
}): Promise<FinishResult> {
|
|
209
|
+
const state = o.state || "";
|
|
210
|
+
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 };
|
|
211
|
+
const row: any = await ctx.DB.prepare("DELETE FROM oidc_txns WHERE state = ? RETURNING *").bind(state).first();
|
|
212
|
+
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 };
|
|
213
|
+
const userCode = row.user_code || null;
|
|
214
|
+
if (Date.now() >= row.expires_ms) return { ok: false, error: "This sign-in expired. Nothing was approved.", reason: "expired", userCode };
|
|
215
|
+
if (o.error || !o.code) return { ok: false, error: "Sign-in was cancelled. Nothing was approved.", reason: "cancelled", userCode };
|
|
216
|
+
const gate = ctx.gates.approvalOidc;
|
|
217
|
+
const secret = ctx.approvalClientSecret();
|
|
218
|
+
if (!approvalPolicy(gate).oidc || !secret) return { ok: false, error: "OpenID Connect approval is not configured.", reason: "config", userCode };
|
|
219
|
+
let disc: Discovery;
|
|
220
|
+
try { disc = await discover(gate.issuer); }
|
|
221
|
+
catch { return { ok: false, error: UNREACHABLE, reason: "discovery", userCode }; }
|
|
222
|
+
let idToken = "";
|
|
223
|
+
try {
|
|
224
|
+
const body = new URLSearchParams({
|
|
225
|
+
grant_type: "authorization_code", code: o.code, redirect_uri: o.redirectUri,
|
|
226
|
+
client_id: gate.clientId, client_secret: secret, code_verifier: row.verifier,
|
|
227
|
+
});
|
|
228
|
+
const res = await fetch(disc.token_endpoint, {
|
|
229
|
+
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
230
|
+
body, redirect: "error", signal: AbortSignal.timeout(FETCH_MS),
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok) return { ok: false, error: UNREACHABLE, reason: "token", userCode };
|
|
233
|
+
const tok = await readJson(res, 65536);
|
|
234
|
+
idToken = typeof tok.id_token === "string" ? tok.id_token : "";
|
|
235
|
+
} catch { return { ok: false, error: UNREACHABLE, reason: "token", userCode }; }
|
|
236
|
+
let jwks: { keys?: any[] };
|
|
237
|
+
try { jwks = await getJson(disc.jwks_uri); }
|
|
238
|
+
catch { return { ok: false, error: UNREACHABLE, reason: "jwks", userCode }; }
|
|
239
|
+
const verified = await verifyIdToken(idToken, jwks, { iss: gate.issuer, aud: gate.clientId, nonce: row.nonce, subjects: gate.allowedSubjects });
|
|
240
|
+
if ("reason" in verified) return { ok: false, error: FAIL, reason: verified.reason, userCode };
|
|
241
|
+
let payload: Record<string, unknown> = {};
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(row.payload_json || "{}");
|
|
244
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed;
|
|
245
|
+
} catch { /* empty payload */ }
|
|
246
|
+
return { ok: true, userCode, payload, subject: verified.subject };
|
|
247
|
+
}
|