pombus 0.1.3 → 0.1.5

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.
@@ -0,0 +1,588 @@
1
+ // R3 — the hand-written OAuth 2.1 Authorization Server for the Pombus remote MCP endpoint, now a
2
+ // DELEGATING front-end (the "Sign in with the webmail" pattern). The HUMAN consent no longer happens
3
+ // here: it runs in the WEBMAIL (mail.pombus.com), the origin that holds the human's session cookie and
4
+ // renders the toggle-based consent screen. The Worker keeps everything that must NEVER leave it — PKCE,
5
+ // the redirect_uri whitelist, the client registry, state, the code — and delegates only the human
6
+ // decision, exactly as an OAuth client delegates to an identity provider.
7
+ //
8
+ // The three Worker surfaces:
9
+ // - GET /authorize : validate, stash a PendingRequest, 302 to the webmail consent URL (signed).
10
+ // - GET /authorize/return : the webmail bounces back here (signed) with a single-use HANDOFF code; we
11
+ // mint OUR authorization code bound to that handoff and 302 to the agent.
12
+ // - POST /token : exchange code + PKCE verifier; REDEEM the handoff via the WEB service
13
+ // binding to get the child key, and build the token-set.
14
+ //
15
+ // Multi-org "Model D" (founder decision, final): one access token reaches ONLY the orgs explicitly
16
+ // authorized, never the human's whole org universe. The token is a SET of {org → child key}; the agent
17
+ // switches the active org with a tool, and adding an org re-runs consent for that org alone.
18
+ //
19
+ // Security invariants (audited black-box, AGENTS.md §5):
20
+ // - Fail-closed everywhere. validateAccessToken returns null on anything off.
21
+ // - PKCE S256 stays IN THE WORKER: the code_verifier is never sent to the webmail. `plain` is refused.
22
+ // - Single-use: pending request (rid), handoff, authorization code — each consumed exactly once.
23
+ // - redirect_uri exact-match against the client whitelist (no open redirect); state echoed.
24
+ // - HMAC signs both 302 hops (mcp-consent.<rid>.<ctx> outbound, mcp-complete.<rid>.<handoff> inbound)
25
+ // so a third party cannot forge a consent return NOR tamper with the signed ctx (scope + redirect
26
+ // host) the webmail reads. The secret is shared with the webmail only.
27
+ // - Anti-org-injection: attachOrg only when the redeem's ownerHash equals the token's owner — an
28
+ // attacker holding the access token cannot graft an org without the owner's webmail session.
29
+ // - Log hygiene: this module logs NOTHING — no child key, access token, handoff, verifier, ownerHash
30
+ // or signature ever reaches a log line.
31
+ /** The registered clients. loopback + the MCP Inspector callback cover local use; claude.ai is hosted. */
32
+ export const DEFAULT_CLIENTS = [
33
+ {
34
+ clientId: "pombus-mcp",
35
+ redirectUris: [
36
+ "http://127.0.0.1:6274/oauth/callback", // MCP Inspector default
37
+ "http://localhost:6274/oauth/callback",
38
+ "https://claude.ai/api/mcp/auth_callback", // a hosted MCP client's fixed callback
39
+ ],
40
+ },
41
+ ];
42
+ /** Ceiling per map (sec audit). Pending requests + clients + links EVICT the oldest when full (a flood
43
+ * only rolls the window, never locks a human out); codes and tokens — which need a real credential —
44
+ * are refused instead, so no live grant is dropped. */
45
+ export const STORE_CAP = 1000;
46
+ function hasRoom(m) {
47
+ if (m.size < STORE_CAP)
48
+ return true;
49
+ const now = Date.now();
50
+ for (const [k, v] of m)
51
+ if (v.expiresAt < now)
52
+ m.delete(k);
53
+ return m.size < STORE_CAP;
54
+ }
55
+ export class OAuthStore {
56
+ requests = new Map();
57
+ codes = new Map();
58
+ tokens = new Map();
59
+ links = new Map();
60
+ clients = new Map();
61
+ async putRequest(id, r) {
62
+ if (!hasRoom(this.requests))
63
+ this.requests.delete(this.requests.keys().next().value);
64
+ this.requests.set(id, r);
65
+ }
66
+ async takeRequest(id) {
67
+ const r = this.requests.get(id);
68
+ if (!r)
69
+ return undefined;
70
+ this.requests.delete(id);
71
+ if (r.expiresAt < Date.now())
72
+ return undefined;
73
+ return r;
74
+ }
75
+ async putCode(code, c) {
76
+ if (!hasRoom(this.codes))
77
+ return false;
78
+ this.codes.set(code, c);
79
+ return true;
80
+ }
81
+ /** Single-use: take and DELETE. Inside a DO the input gate serializes this get-then-delete, so a
82
+ * replay racing the first exchange cannot both read the same code. */
83
+ async takeCode(code) {
84
+ const c = this.codes.get(code);
85
+ if (!c)
86
+ return undefined;
87
+ this.codes.delete(code);
88
+ if (c.expiresAt < Date.now())
89
+ return undefined;
90
+ return c;
91
+ }
92
+ /** Checked BEFORE /token builds a token-set, so a full store never strands a freshly redeemed key. The
93
+ * token map is keyed on `tokenExp` (not the shared `expiresAt`), so it sweeps its own expired sets
94
+ * before judging room — no eviction of a live grant (unlike pending requests, a token is refused when
95
+ * truly full, so nobody's active session is dropped). */
96
+ async tokenRoom() {
97
+ if (this.tokens.size < STORE_CAP)
98
+ return true;
99
+ const now = Date.now();
100
+ for (const [k, v] of this.tokens)
101
+ if (v.tokenExp < now)
102
+ this.tokens.delete(k);
103
+ return this.tokens.size < STORE_CAP;
104
+ }
105
+ async putToken(token, t) {
106
+ this.tokens.set(token, t);
107
+ }
108
+ async getToken(token) {
109
+ const t = this.tokens.get(token);
110
+ if (!t)
111
+ return undefined;
112
+ if (t.tokenExp < Date.now()) {
113
+ this.tokens.delete(token);
114
+ return undefined;
115
+ }
116
+ return t;
117
+ }
118
+ async attachOrg(token, orgId, entry, ownerHash) {
119
+ const t = await this.getToken(token);
120
+ if (!t)
121
+ return false;
122
+ if (t.owner !== ownerHash)
123
+ return false; // anti-injection: only the owner's webmail session may add
124
+ t.orgs[orgId] = entry;
125
+ return true;
126
+ }
127
+ async setActiveOrg(token, orgId) {
128
+ const t = await this.getToken(token);
129
+ if (!t)
130
+ return false;
131
+ if (!(orgId in t.orgs))
132
+ return false;
133
+ t.activeOrg = orgId;
134
+ return true;
135
+ }
136
+ async getActiveChild(token) {
137
+ const t = await this.getToken(token);
138
+ if (!t)
139
+ return undefined;
140
+ const entry = t.orgs[t.activeOrg];
141
+ if (!entry)
142
+ return undefined;
143
+ if (entry.expiresAt < Date.now())
144
+ return undefined; // the active org's key lapsed; re-authorize
145
+ return { orgId: t.activeOrg, childKey: entry.childKey, perms: entry.perms };
146
+ }
147
+ async putLink(linkToken, accessToken) {
148
+ if (!hasRoom(this.links))
149
+ this.links.delete(this.links.keys().next().value);
150
+ this.links.set(linkToken, { accessToken, expiresAt: Date.now() + REQUEST_TTL_MS });
151
+ return true;
152
+ }
153
+ async takeLink(linkToken) {
154
+ const l = this.links.get(linkToken);
155
+ if (!l)
156
+ return undefined;
157
+ this.links.delete(linkToken);
158
+ if (l.expiresAt < Date.now())
159
+ return undefined;
160
+ return l.accessToken;
161
+ }
162
+ /** /register is PUBLIC — cap it like pending requests: sweep, then EVICT the oldest rather than refuse,
163
+ * so a flood only rolls the window. Never returns false in practice; the boolean keeps putCode's shape. */
164
+ async putClient(clientId, redirectUris) {
165
+ if (!hasRoom(this.clients))
166
+ this.clients.delete(this.clients.keys().next().value);
167
+ this.clients.set(clientId, { redirectUris, expiresAt: Date.now() + REGISTER_TTL_MS });
168
+ return true;
169
+ }
170
+ async getClient(clientId) {
171
+ const c = this.clients.get(clientId);
172
+ if (!c)
173
+ return undefined;
174
+ if (c.expiresAt < Date.now()) {
175
+ this.clients.delete(clientId);
176
+ return undefined;
177
+ }
178
+ return c.redirectUris;
179
+ }
180
+ /** Test seam: wipe everything between cases. */
181
+ async reset() {
182
+ this.requests.clear();
183
+ this.codes.clear();
184
+ this.tokens.clear();
185
+ this.links.clear();
186
+ this.clients.clear();
187
+ }
188
+ }
189
+ /** The process-wide store the Worker uses when no Durable Object is bound (tests, `wrangler dev` before
190
+ * the DO migration). A test can construct its own OAuthStore instead. */
191
+ export const store = new OAuthStore();
192
+ // --- lifetimes ------------------------------------------------------------------------------------
193
+ const REQUEST_TTL_MS = 10 * 60 * 1000; // a human has 10 min to finish consent on the webmail
194
+ const CODE_TTL_MS = 60 * 1000; // authorization code: 60s, single-use
195
+ const TOKEN_TTL_MS = 60 * 60 * 1000; // access token + minted child keys: 1h
196
+ const REGISTER_TTL_MS = 30 * 24 * 60 * 60 * 1000; // a dynamic client registration (bounds growth only)
197
+ // --- crypto helpers (Web Crypto, present in Workers and Node >=20) --------------------------------
198
+ /** base64url WITHOUT padding (RFC 7636 §A). */
199
+ function b64url(bytes) {
200
+ let s = "";
201
+ for (const b of bytes)
202
+ s += String.fromCharCode(b);
203
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
204
+ }
205
+ /** A cryptographically-random opaque token (32 bytes → 43 base64url chars). Unguessable: request ids,
206
+ * authorization codes, access tokens, link tokens. */
207
+ function randomToken() {
208
+ const b = new Uint8Array(32);
209
+ crypto.getRandomValues(b);
210
+ return b64url(b);
211
+ }
212
+ /** PKCE S256: BASE64URL(SHA-256(ASCII(code_verifier))). Compared (constant-time) against the stored
213
+ * challenge AT THE WORKER — the verifier never leaves this process to the webmail. */
214
+ async function s256(verifier) {
215
+ const data = new TextEncoder().encode(verifier);
216
+ const digest = await crypto.subtle.digest("SHA-256", data);
217
+ return b64url(new Uint8Array(digest));
218
+ }
219
+ /** HMAC-SHA256(secret, message) → base64url. Signs both 302 hops so a third party cannot forge a
220
+ * consent return. stdlib Web Crypto, no dependency. */
221
+ async function hmac(secret, message) {
222
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
223
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
224
+ return b64url(new Uint8Array(sig));
225
+ }
226
+ /** Constant-time string compare (timing-oracle fence on code_challenge / signatures). Both operands are
227
+ * base64url, so byte length equals char length. */
228
+ function timingSafeEqual(a, b) {
229
+ if (a.length !== b.length)
230
+ return false;
231
+ let diff = 0;
232
+ for (let i = 0; i < a.length; i++)
233
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
234
+ return diff === 0;
235
+ }
236
+ // --- helpers --------------------------------------------------------------------------------------
237
+ function findClient(clients, clientId) {
238
+ return clients.find((c) => c.clientId === clientId);
239
+ }
240
+ /** Resolve a client_id: the FIXED registry first, then a DYNAMICALLY-REGISTERED client (RFC 7591). A
241
+ * registered client is its stored redirect_uris in the same shape, so the exact-match fence runs
242
+ * identically. Unknown → undefined (fail-closed). */
243
+ async function resolveClient(deps, clientId) {
244
+ const fixed = findClient(deps.clients, clientId);
245
+ if (fixed)
246
+ return fixed;
247
+ const uris = await deps.store.getClient(clientId);
248
+ if (uris)
249
+ return { clientId, redirectUris: uris };
250
+ return undefined;
251
+ }
252
+ /** redirect_uri exact-match against the client's whitelist — byte-identical or rejected (open-redirect fence). */
253
+ function redirectAllowed(client, redirectUri) {
254
+ return client.redirectUris.some((u) => u === redirectUri);
255
+ }
256
+ /** A content-free 400. OAuth errors on the AUTHORIZE side must not become an oracle; `detail` is a fixed
257
+ * non-sensitive string we choose (never caller input, never a secret). */
258
+ function badRequest(detail) {
259
+ return new Response(detail, {
260
+ status: 400,
261
+ headers: { "content-type": "text/plain; charset=utf-8", "x-content-type-options": "nosniff" },
262
+ });
263
+ }
264
+ /** Content-free 503 — the store is full, or a required binding/secret is missing (fail-closed). */
265
+ function unavailable() {
266
+ return new Response(null, { status: 503, headers: { "retry-after": "60" } });
267
+ }
268
+ /** Both POST bodies are a handful of short form fields. 8 KiB stops an arbitrary buffered body. */
269
+ export const MAX_FORM_BYTES = 8 * 1024;
270
+ async function readFormCapped(req) {
271
+ const chunks = await readBodyCapped(req);
272
+ if (!chunks)
273
+ return null;
274
+ const headers = { "content-type": req.headers.get("content-type") ?? "" };
275
+ try {
276
+ return await new Response(new Blob(chunks), { headers }).formData();
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ }
282
+ /** Read at most MAX_FORM_BYTES, enforced on the bytes actually read (not a content-length the caller
283
+ * may omit or lie about). null = too large or unreadable. */
284
+ async function readBodyCapped(req) {
285
+ if (Number(req.headers.get("content-length") ?? 0) > MAX_FORM_BYTES)
286
+ return null;
287
+ const reader = req.body?.getReader();
288
+ const chunks = [];
289
+ let size = 0;
290
+ if (reader) {
291
+ for (;;) {
292
+ const { done, value } = await reader.read();
293
+ if (done)
294
+ break;
295
+ size += value.byteLength;
296
+ if (size > MAX_FORM_BYTES) {
297
+ await reader.cancel();
298
+ return null;
299
+ }
300
+ chunks.push(value);
301
+ }
302
+ }
303
+ return chunks;
304
+ }
305
+ async function readJsonCapped(req) {
306
+ const chunks = await readBodyCapped(req);
307
+ if (!chunks)
308
+ return null;
309
+ try {
310
+ const text = await new Response(new Blob(chunks)).text();
311
+ const parsed = JSON.parse(text);
312
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
313
+ return null;
314
+ return parsed;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ function tooLarge() {
321
+ return new Response(null, { status: 413 });
322
+ }
323
+ /** The RFC 6749 token error (coarse OAuth code only — never which part failed). */
324
+ function tokenError(error) {
325
+ return new Response(JSON.stringify({ error }), {
326
+ status: 400,
327
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
328
+ });
329
+ }
330
+ /** Build a 302 to the redirect_uri with code+state (or error+state). The redirect_uri was already proven
331
+ * an exact whitelist match, so this cannot be an open redirect. */
332
+ function oauthRedirect(redirectUri, params) {
333
+ const u = new URL(redirectUri);
334
+ for (const [k, v] of Object.entries(params))
335
+ u.searchParams.set(k, v);
336
+ return new Response(null, { status: 302, headers: { location: u.toString() } });
337
+ }
338
+ // --- GET /authorize (delegating front-end) --------------------------------------------------------
339
+ /** Validate the authorization request and 302 to the WEBMAIL consent screen. Everything that must stay
340
+ * server-side — client, redirect_uri, PKCE challenge, state — is stashed in a single-use PendingRequest
341
+ * under a random `rid`; only `rid` + an HMAC signature travel to the webmail. Rejects (400, no redirect)
342
+ * when the client/redirect_uri is unknown; 400 for every other protocol error (the request never
343
+ * reached a trusted redirect). Fail-closed: no webmail secret/URL configured → 503, never a redirect. */
344
+ export async function handleAuthorizeGet(req, deps) {
345
+ const u = new URL(req.url);
346
+ const q = u.searchParams;
347
+ const clientId = q.get("client_id") ?? "";
348
+ const redirectUri = q.get("redirect_uri") ?? "";
349
+ const responseType = q.get("response_type") ?? "";
350
+ const codeChallenge = q.get("code_challenge") ?? "";
351
+ const method = q.get("code_challenge_method") ?? "";
352
+ const state = q.get("state") ?? "";
353
+ const scope = q.get("scope") ?? "";
354
+ const linkTokenRaw = q.get("link_token") ?? "";
355
+ const client = await resolveClient(deps, clientId);
356
+ if (!client)
357
+ return badRequest("invalid_client");
358
+ if (!redirectAllowed(client, redirectUri))
359
+ return badRequest("invalid_redirect_uri");
360
+ if (responseType !== "code")
361
+ return badRequest("unsupported_response_type");
362
+ if (method !== "S256")
363
+ return badRequest("invalid_pkce"); // plain is forbidden (OAuth 2.1)
364
+ if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(codeChallenge))
365
+ return badRequest("invalid_pkce");
366
+ if (!state)
367
+ return badRequest("invalid_state");
368
+ // Fail-closed: without the webmail consent URL and the shared secret we cannot delegate OR sign the
369
+ // hop — refuse rather than bounce somewhere unsigned. 503 is honest ("unavailable"), not an oracle.
370
+ if (!deps.webConsentUrl || !deps.webLinkSecret)
371
+ return unavailable();
372
+ // The ADD-ORG flow (select_organization): if a valid link_token is present, consume it and carry the
373
+ // tied access token into the PendingRequest so /token attaches the new org to the SAME access token.
374
+ // An invalid/expired link_token is simply ignored (treated as a first authorization) — never an error
375
+ // that would tell an attacker whether a link_token exists.
376
+ let linkToken;
377
+ if (linkTokenRaw) {
378
+ const tied = await deps.store.takeLink(linkTokenRaw);
379
+ if (tied)
380
+ linkToken = tied;
381
+ }
382
+ const rid = randomToken();
383
+ await deps.store.putRequest(rid, {
384
+ clientId,
385
+ redirectUri,
386
+ state,
387
+ codeChallenge,
388
+ scope,
389
+ ...(linkToken !== undefined ? { linkToken } : {}),
390
+ expiresAt: Date.now() + REQUEST_TTL_MS,
391
+ });
392
+ // The webmail needs the scope (to pre-check the toggles) and the agent's host (to show "who is
393
+ // asking") — and there is no reverse channel. We carry that context SIGNED on the 302: scope and
394
+ // redirect host are not secrets (OAuth already puts scope/redirect on the authorize URL), and
395
+ // folding the ctx into the HMAC input makes it tamper-evident. PKCE/redirect_uri/client/state stay
396
+ // in the Worker's PendingRequest and never travel.
397
+ const ctx = b64url(new TextEncoder().encode(JSON.stringify({ scope, redirectHost: new URL(redirectUri).host })));
398
+ const sig = await hmac(deps.webLinkSecret, `mcp-consent.${rid}.${ctx}`);
399
+ const consent = new URL(deps.webConsentUrl);
400
+ consent.searchParams.set("rid", rid);
401
+ consent.searchParams.set("ctx", ctx);
402
+ consent.searchParams.set("sig", sig);
403
+ return new Response(null, { status: 302, headers: { location: consent.toString() } });
404
+ }
405
+ // --- GET /authorize/return (webmail → worker) -----------------------------------------------------
406
+ /** The webmail bounces the human back here after consent, carrying the single-use `handoff` it minted,
407
+ * signed with mcp-complete.<rid>.<handoff>. We verify the signature, consume the PendingRequest (rid),
408
+ * and issue OUR authorization code bound to redirect_uri + PKCE challenge + the handoff. Then 302 to the
409
+ * agent's redirect_uri with ?code&state (exact-match already proven — no open redirect). A bad signature
410
+ * or unknown rid is a bare 400 (no redirect — we have nothing trusted to bounce to). */
411
+ export async function handleAuthorizeReturn(req, deps) {
412
+ const q = new URL(req.url).searchParams;
413
+ const rid = q.get("rid") ?? "";
414
+ const handoff = q.get("handoff") ?? "";
415
+ const sig = q.get("sig") ?? "";
416
+ if (!deps.webLinkSecret)
417
+ return unavailable(); // fail-closed: cannot verify the webmail's signature
418
+ if (!rid || !handoff || !sig)
419
+ return badRequest("invalid_request");
420
+ const expected = await hmac(deps.webLinkSecret, `mcp-complete.${rid}.${handoff}`);
421
+ if (!timingSafeEqual(sig, expected))
422
+ return badRequest("invalid_request"); // forged return
423
+ const pending = await deps.store.takeRequest(rid); // single-use
424
+ if (!pending)
425
+ return badRequest("invalid_request");
426
+ // Re-check the client/redirect defensively (fail-closed) before building any redirect.
427
+ const client = await resolveClient(deps, pending.clientId);
428
+ if (!client || !redirectAllowed(client, pending.redirectUri))
429
+ return badRequest("invalid_request");
430
+ const code = randomToken();
431
+ const ok = await deps.store.putCode(code, {
432
+ clientId: pending.clientId,
433
+ redirectUri: pending.redirectUri,
434
+ codeChallenge: pending.codeChallenge,
435
+ handoffCode: handoff,
436
+ ...(pending.linkToken !== undefined ? { linkToken: pending.linkToken } : {}),
437
+ expiresAt: Date.now() + CODE_TTL_MS,
438
+ });
439
+ if (!ok)
440
+ return oauthRedirect(pending.redirectUri, { error: "temporarily_unavailable", state: pending.state });
441
+ return oauthRedirect(pending.redirectUri, { code, state: pending.state });
442
+ }
443
+ // --- POST /token (redeem via the WEB service binding) ---------------------------------------------
444
+ /** Exchange authorization_code + code_verifier for an access token. Verifies PKCE S256 (IN THE WORKER),
445
+ * the bound redirect_uri + client, consumes the single-use code, then REDEEMS the handoff via the WEB
446
+ * binding to obtain the child key + org metadata. On a first authorization it builds a fresh token-set
447
+ * (one org); on an add-org exchange (link_token present) it attaches the org to the EXISTING token-set
448
+ * only if the redeem's ownerHash matches the token's owner (anti-injection). The child key and handoff
449
+ * are never placed in the response or a log. */
450
+ export async function handleToken(req, deps) {
451
+ const form = await readFormCapped(req);
452
+ if (!form)
453
+ return tooLarge();
454
+ const grantType = String(form.get("grant_type") ?? "");
455
+ const code = String(form.get("code") ?? "");
456
+ const verifier = String(form.get("code_verifier") ?? "");
457
+ const redirectUri = String(form.get("redirect_uri") ?? "");
458
+ const clientId = String(form.get("client_id") ?? "");
459
+ if (grantType !== "authorization_code")
460
+ return tokenError("unsupported_grant_type");
461
+ // Full token store: refuse BEFORE consuming the code (the client can retry) and BEFORE redeeming.
462
+ if (!(await deps.store.tokenRoom()))
463
+ return unavailable();
464
+ const issued = await deps.store.takeCode(code); // single-use
465
+ if (!issued)
466
+ return tokenError("invalid_grant");
467
+ if (issued.clientId !== clientId)
468
+ return tokenError("invalid_grant");
469
+ if (issued.redirectUri !== redirectUri)
470
+ return tokenError("invalid_grant");
471
+ if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier))
472
+ return tokenError("invalid_grant");
473
+ const computed = await s256(verifier);
474
+ if (!timingSafeEqual(computed, issued.codeChallenge))
475
+ return tokenError("invalid_grant");
476
+ // Fail-closed: no WEB binding configured → we cannot obtain a child key → 503 (not an invalid_grant,
477
+ // which would wrongly blame the client). The code was already consumed; the client re-authorizes.
478
+ if (!deps.web)
479
+ return unavailable();
480
+ let redeemed;
481
+ try {
482
+ redeemed = await deps.web.redeemMcpHandoff(issued.handoffCode);
483
+ }
484
+ catch {
485
+ return unavailable(); // the webmail is unreachable — availability failure, not a bad grant
486
+ }
487
+ if (!redeemed)
488
+ return tokenError("invalid_grant"); // unknown/consumed handoff
489
+ const now = Date.now();
490
+ const entry = {
491
+ childKey: redeemed.childKey,
492
+ perms: redeemed.perms,
493
+ label: redeemed.label,
494
+ expiresAt: now + TOKEN_TTL_MS,
495
+ };
496
+ if (issued.linkToken === undefined) {
497
+ // First authorization: a fresh token-set with exactly one org, pointed at it.
498
+ const accessToken = randomToken();
499
+ await deps.store.putToken(accessToken, {
500
+ activeOrg: redeemed.orgId,
501
+ orgs: { [redeemed.orgId]: entry },
502
+ owner: redeemed.ownerHash,
503
+ tokenExp: now + TOKEN_TTL_MS,
504
+ });
505
+ return tokenResponse(accessToken);
506
+ }
507
+ // Add-org: resolve the existing access token the link_token was tied to, and attach ONLY if the
508
+ // redeem's ownerHash matches the token's owner. The link_token was already consumed at /authorize;
509
+ // here we resolve it to the access token it carried through the PendingRequest → IssuedCode.
510
+ const accessToken = issued.linkToken;
511
+ const attached = await deps.store.attachOrg(accessToken, redeemed.orgId, entry, redeemed.ownerHash);
512
+ if (!attached)
513
+ return tokenError("invalid_grant"); // owner mismatch or token gone — never grafts
514
+ await deps.store.setActiveOrg(accessToken, redeemed.orgId);
515
+ // The agent already holds this access token; return it again (idempotent confirmation).
516
+ return tokenResponse(accessToken);
517
+ }
518
+ /** The RFC 6749 §5.1 success body. No `scope`: we do not enumerate the grant (the child key is it). */
519
+ function tokenResponse(accessToken) {
520
+ return new Response(JSON.stringify({
521
+ access_token: accessToken,
522
+ token_type: "Bearer",
523
+ expires_in: Math.floor(TOKEN_TTL_MS / 1000),
524
+ }), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store" } });
525
+ }
526
+ // --- POST /register (Dynamic Client Registration, RFC 7591) ---------------------------------------
527
+ function registerError(error) {
528
+ return new Response(JSON.stringify({ error }), {
529
+ status: 400,
530
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
531
+ });
532
+ }
533
+ /** The ONLY redirect_uri shapes a dynamic client may register (anti-open-redirect anchor): https, or a
534
+ * loopback (127.0.0.1 / localhost) http callback. Anything else rejects the WHOLE registration. */
535
+ function isAllowedRedirectUri(raw) {
536
+ if (typeof raw !== "string" || raw.length === 0)
537
+ return false;
538
+ let u;
539
+ try {
540
+ u = new URL(raw);
541
+ }
542
+ catch {
543
+ return false;
544
+ }
545
+ if (u.protocol === "https:")
546
+ return true;
547
+ if (u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost"))
548
+ return true;
549
+ return false;
550
+ }
551
+ /** Handle POST /register. Validates redirect_uris, generates a random public client_id, stores the
552
+ * registration (capped), returns 201 with the RFC 7591 client-information fields. No secret issued —
553
+ * PKCE authenticates the exchange. Nothing is logged. */
554
+ export async function handleRegister(req, deps) {
555
+ const body = await readJsonCapped(req);
556
+ if (!body)
557
+ return registerError("invalid_client_metadata");
558
+ const redirectUris = body["redirect_uris"];
559
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
560
+ return registerError("invalid_redirect_uri");
561
+ }
562
+ for (const uri of redirectUris) {
563
+ if (!isAllowedRedirectUri(uri))
564
+ return registerError("invalid_redirect_uri");
565
+ }
566
+ const uris = redirectUris;
567
+ const clientId = randomToken(); // public, high-entropy; no secret
568
+ await deps.store.putClient(clientId, uris);
569
+ return new Response(JSON.stringify({
570
+ client_id: clientId,
571
+ redirect_uris: uris,
572
+ token_endpoint_auth_method: "none",
573
+ grant_types: ["authorization_code"],
574
+ response_types: ["code"],
575
+ }), { status: 201, headers: { "content-type": "application/json", "cache-control": "no-store" } });
576
+ }
577
+ // --- Bearer resolution ----------------------------------------------------------------------------
578
+ /** Resolve a Bearer access token to the child key of its ACTIVE org (Model D), or null to refuse
579
+ * (fail-closed). getActiveChild is the ONLY source of the credential — org↔org isolation depends on no
580
+ * other path producing a child key. The returned value is the minted child key; NEVER log it. */
581
+ export async function validateAccessToken(authorizationHeader, store) {
582
+ const m = /^Bearer (.+)$/.exec((authorizationHeader ?? "").trim());
583
+ const token = m?.[1]?.trim();
584
+ if (!token)
585
+ return null;
586
+ const active = await store.getActiveChild(token);
587
+ return active ? active.childKey : null;
588
+ }
@@ -0,0 +1,80 @@
1
+ // R2d — the friendly root page. A HUMAN who opens the MCP endpoint in a browser (GET, Accept text/html,
2
+ // no Bearer) lands here instead of a bare 401; a real MCP client never does (it POSTs with Accept
3
+ // application/json / text/event-stream and a Bearer, so it falls through to the normal fail-closed MCP
4
+ // path — the worker, not this module, makes that distinction). Self-contained HTML: no script, no
5
+ // external font, no external image (same CSP-friendliness as the consent screen), tokens ported from it.
6
+ //
7
+ // Everything visible comes from the i18n dictionary (oauth-i18n.ts ROOT_TEXT) in the three mandated
8
+ // locales. There are no attacker-controlled values on this page (no query reflection), but the one
9
+ // dynamic value (the localized strings) is still run through esc() as a belt-and-braces XSS fence.
10
+ import { ROOT_TEXT } from "./oauth-i18n.js";
11
+ /** HTML-escape for text / double-quoted attribute contexts (same five chars as consent.ts esc). */
12
+ function esc(s) {
13
+ return s
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&#39;");
19
+ }
20
+ /** Localized root-page string. Looked up in ROOT_TEXT (not the consent dict); throws on an unknown id
21
+ * (every id is a literal here) so a missing string can never ship silently — mirrors consent's t(). */
22
+ function t(id, locale) {
23
+ const entry = ROOT_TEXT[id];
24
+ if (!entry)
25
+ throw new Error(`root i18n: unknown id ${id}`);
26
+ return entry[locale];
27
+ }
28
+ /** The Pombus horizontal lockup, identical to the consent screen's (currentColor, no external asset). */
29
+ const BRAND_SVG = `<svg role="img" aria-label="Pombus" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 198284 43410"><g><g><g><path fill="currentColor" d="M17584 0l4226 0c9499,0 17302,7635 17579,17072l5 0 0 512 0 18023 -19697 7803 -19697 -7803 0 -18023 0 -512 10 0c272,-9437 8079,-17072 17574,-17072zm-2625 33078l3839 4217c239,263 545,397 899,397 354,0 660,-134 899,-397l3843 -4217c43,-43 77,-95 115,-143l956 0c746,0 1382,-416 1688,-1095 296,-683 177,-1434 -325,-1984l-5814 -6377c-363,-397 -827,-603 -1362,-603 -536,0 -999,206 -1358,603l-5818 6377c-502,550 -622,1301 -320,1984 301,679 936,1095 1682,1095l961 0c34,48 67,100 115,143l0 0zm-6268 -23975l7831 7620c371,360 581,855 581,1372 0,517 -210,1012 -580,1372l-7832 7621c-357,347 -835,541 -1333,541 -1057,0 -1914,-856 -1914,-1913 0,-517 209,-1012 580,-1372l6425 -6249 -6425 -6248c-371,-360 -580,-856 -580,-1372 0,-1057 857,-1914 1914,-1914 498,0 976,195 1333,542zm22212 0l-7831 7620c-371,360 -580,855 -580,1372 0,517 209,1012 580,1372l7831 7621c357,347 836,541 1334,541 1056,0 1913,-856 1913,-1913 0,-517 -209,-1012 -579,-1372l-6426 -6249 6426 -6248c370,-360 579,-856 579,-1372 0,-1057 -857,-1914 -1913,-1914 -498,0 -977,195 -1334,542l0 0zm-28465 8992c0,1405 1139,2544 2544,2544 1404,0 2543,-1139 2543,-2544 0,-1404 -1139,-2543 -2543,-2543 -1405,0 -2544,1139 -2544,2543l0 0zm29631 0c0,1405 1139,2544 2544,2544 1404,0 2543,-1139 2543,-2544 0,-1404 -1139,-2543 -2543,-2543 -1405,0 -2544,1139 -2544,2543z"/><path fill="currentColor" fill-rule="nonzero" d="M68230 39187l0 -18262 3284 0 80 3043 -347 -160c356,-1050 939,-1847 1749,-2389 810,-543 1758,-815 2844,-815 1353,0 2478,338 3377,1015 899,676 1571,1575 2016,2696 445,1122 668,2368 668,3738 0,1371 -227,2617 -681,3738 -454,1122 -1131,2021 -2029,2697 -899,677 -2025,1015 -3378,1015 -712,0 -1375,-125 -1989,-374 -614,-249 -1139,-605 -1575,-1068 -436,-463 -743,-1006 -922,-1629l321 -267 0 7022 -3418 0zm6782 -6461c1033,0 1847,-414 2443,-1242 596,-827 895,-1971 895,-3431 0,-1459 -299,-2603 -895,-3430 -596,-828 -1410,-1242 -2443,-1242 -694,0 -1290,174 -1789,521 -498,347 -885,867 -1161,1562 -276,694 -414,1557 -414,2589 0,1033 133,1896 400,2590 267,695 654,1215 1162,1562 507,347 1108,521 1802,521z"/></g></g></g></svg>`;
30
+ /** Render the root page in the given locale. Pure string → the worker sets the 200 + content-type. */
31
+ export function renderRoot(locale) {
32
+ const L = locale;
33
+ const title = esc(t("root_title", L));
34
+ const heading = esc(t("root_heading", L));
35
+ const body = esc(t("root_body", L));
36
+ const site = esc(t("root_site", L));
37
+ return `<!doctype html>
38
+ <html lang="${esc(L)}">
39
+ <head>
40
+ <meta charset="utf-8" />
41
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
42
+ <title>${title}</title>
43
+ <style>
44
+ :root {
45
+ color-scheme: light dark;
46
+ --card: #ffffff; --surface: #f4f5f6; --border: #e4e6e9; --ink: #0b0e11; --muted: #5c636b; --dim: #878e96;
47
+ }
48
+ @media (prefers-color-scheme: dark) {
49
+ :root {
50
+ --card: #0b0e11; --surface: #171c22; --border: #262d35; --ink: #e6e9ec; --muted: #8a9099; --dim: #8a9099;
51
+ }
52
+ }
53
+ * { box-sizing: border-box; }
54
+ body {
55
+ font: 14px/1.6 system-ui, -apple-system, sans-serif; color: var(--ink); background: var(--surface);
56
+ min-height: 100vh; min-height: 100dvh; margin: 0; padding: 44px 20px;
57
+ display: flex; align-items: center; justify-content: center;
58
+ }
59
+ .card {
60
+ width: 100%; max-width: 440px; background: var(--card); border: 1px solid var(--border);
61
+ border-radius: 20px; padding: 32px 30px; text-align: center;
62
+ box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.35);
63
+ }
64
+ .brand { display: flex; justify-content: center; margin-bottom: 18px; color: var(--ink); }
65
+ .brand svg { height: 28px; width: auto; display: block; }
66
+ h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; margin: 0 0 8px; color: var(--ink); }
67
+ p { font-size: 13.5px; color: var(--muted); margin: 0 0 14px; }
68
+ .site { font-size: 12.5px; color: var(--dim); margin: 0; }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <main class="card">
73
+ <span class="brand">${BRAND_SVG}</span>
74
+ <h1>${heading}</h1>
75
+ <p>${body}</p>
76
+ <p class="site">${site}</p>
77
+ </main>
78
+ </body>
79
+ </html>`;
80
+ }