mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2

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.
Files changed (116) hide show
  1. package/README.md +21 -16
  2. package/dist/accounts.d.ts +44 -5
  3. package/dist/accounts.js +267 -25
  4. package/dist/auth.d.ts +11 -4
  5. package/dist/auth.js +85 -78
  6. package/dist/client-config.d.ts +90 -0
  7. package/dist/client-config.js +282 -0
  8. package/dist/client.js +10 -4
  9. package/dist/config-file.d.ts +38 -0
  10. package/dist/config-file.js +117 -0
  11. package/dist/discover.js +57 -3
  12. package/dist/doctor.d.ts +86 -0
  13. package/dist/doctor.js +384 -0
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +52 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -1
  18. package/dist/fanout.js +8 -5
  19. package/dist/fs-atomic.d.ts +3 -0
  20. package/dist/fs-atomic.js +123 -0
  21. package/dist/http-config.d.ts +35 -0
  22. package/dist/http-config.js +113 -0
  23. package/dist/http-transport.d.ts +59 -0
  24. package/dist/http-transport.js +269 -0
  25. package/dist/identity.d.ts +24 -0
  26. package/dist/identity.js +19 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +199 -18
  29. package/dist/master-key.d.ts +53 -0
  30. package/dist/master-key.js +242 -0
  31. package/dist/mcp-token.d.ts +81 -0
  32. package/dist/mcp-token.js +176 -0
  33. package/dist/migrate-config.d.ts +7 -0
  34. package/dist/migrate-config.js +103 -0
  35. package/dist/migrate-tokens.js +4 -3
  36. package/dist/net-tuning.d.ts +8 -0
  37. package/dist/net-tuning.js +25 -0
  38. package/dist/oauth-as.d.ts +52 -0
  39. package/dist/oauth-as.js +457 -0
  40. package/dist/oauth-consent.d.ts +28 -0
  41. package/dist/oauth-consent.js +109 -0
  42. package/dist/open-url.d.ts +1 -0
  43. package/dist/open-url.js +30 -0
  44. package/dist/reauth-hint.d.ts +2 -0
  45. package/dist/reauth-hint.js +15 -0
  46. package/dist/registry-transfer.d.ts +41 -0
  47. package/dist/registry-transfer.js +219 -0
  48. package/dist/registry.d.ts +27 -1
  49. package/dist/registry.js +117 -6
  50. package/dist/scope-catalog.d.ts +19 -0
  51. package/dist/scope-catalog.js +182 -0
  52. package/dist/scope-observability.d.ts +78 -0
  53. package/dist/scope-observability.js +192 -0
  54. package/dist/services.js +1 -1
  55. package/dist/setup-prompt.d.ts +9 -0
  56. package/dist/setup-prompt.js +59 -0
  57. package/dist/ssrf-guard.d.ts +48 -0
  58. package/dist/ssrf-guard.js +242 -0
  59. package/dist/token-store.js +23 -125
  60. package/dist/tools/_errors.d.ts +8 -2
  61. package/dist/tools/_errors.js +97 -6
  62. package/dist/tools/account-wizard.d.ts +33 -0
  63. package/dist/tools/account-wizard.js +284 -0
  64. package/dist/tools/accounts-tool.d.ts +8 -5
  65. package/dist/tools/accounts-tool.js +38 -15
  66. package/dist/tools/admin.js +2 -2
  67. package/dist/tools/calendar.js +2 -2
  68. package/dist/tools/chat.js +2 -2
  69. package/dist/tools/contacts.d.ts +40 -0
  70. package/dist/tools/contacts.js +135 -2
  71. package/dist/tools/docs.d.ts +3 -0
  72. package/dist/tools/docs.js +60 -9
  73. package/dist/tools/drive.d.ts +3 -0
  74. package/dist/tools/drive.js +60 -8
  75. package/dist/tools/forms.js +4 -2
  76. package/dist/tools/generated/_shared.d.ts +1 -3
  77. package/dist/tools/generated/_shared.js +3 -3
  78. package/dist/tools/generated/admin.js +175 -132
  79. package/dist/tools/generated/appsmarket.js +6 -2
  80. package/dist/tools/generated/calendar.js +45 -27
  81. package/dist/tools/generated/chat.js +81 -47
  82. package/dist/tools/generated/classroom.js +131 -104
  83. package/dist/tools/generated/cloudidentity.js +73 -61
  84. package/dist/tools/generated/cloudsearch.js +59 -49
  85. package/dist/tools/generated/contacts.js +22 -14
  86. package/dist/tools/generated/drive.js +51 -35
  87. package/dist/tools/generated/driveactivity.js +5 -1
  88. package/dist/tools/generated/drivelabels.js +30 -25
  89. package/dist/tools/generated/forms.js +7 -3
  90. package/dist/tools/generated/gmail.js +73 -58
  91. package/dist/tools/generated/groupsmigration.js +5 -1
  92. package/dist/tools/generated/groupssettings.js +7 -3
  93. package/dist/tools/generated/keep.js +12 -7
  94. package/dist/tools/generated/licensing.js +11 -7
  95. package/dist/tools/generated/meet.js +20 -13
  96. package/dist/tools/generated/postmaster.js +8 -4
  97. package/dist/tools/generated/reseller.js +22 -17
  98. package/dist/tools/generated/script.js +26 -16
  99. package/dist/tools/generated/sheets.js +11 -7
  100. package/dist/tools/generated/tasks.js +6 -2
  101. package/dist/tools/generated/vault.js +38 -33
  102. package/dist/tools/generated/workspaceevents.js +12 -7
  103. package/dist/tools/gmail-mime.d.ts +38 -8
  104. package/dist/tools/gmail-mime.js +75 -71
  105. package/dist/tools/gmail.d.ts +31 -0
  106. package/dist/tools/gmail.js +483 -99
  107. package/dist/tools/google-api.js +7 -4
  108. package/dist/tools/meet.js +2 -2
  109. package/dist/tools/searchconsole.js +2 -2
  110. package/dist/tools/sheets.js +6 -2
  111. package/dist/tools/slides.js +4 -2
  112. package/dist/tools/tasks.js +2 -2
  113. package/dist/types.d.ts +1 -1
  114. package/dist/write-control.d.ts +24 -1
  115. package/dist/write-control.js +26 -1
  116. package/package.json +26 -7
@@ -0,0 +1,52 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { AuthOutcome, RouteHandler } from './http-transport.js';
3
+ import { ReplayGuard, RefreshStore, type StatePayload } from './mcp-token.js';
4
+ export declare const DCR_MAX_CLIENTS = 1000;
5
+ export declare const DCR_MAX_REDIRECT_URIS = 10;
6
+ export interface GoogleExchangeResult {
7
+ tokens: Record<string, unknown>;
8
+ email?: string;
9
+ }
10
+ export interface AuthServerConfig {
11
+ base: string;
12
+ resourceUri: string;
13
+ secret: Uint8Array;
14
+ ownerEmails: string[];
15
+ cimdIssuers: string[];
16
+ accessTtlSec?: number;
17
+ masterKey: string;
18
+ refreshStorePath: string;
19
+ }
20
+ export interface AuthServerDeps {
21
+ fetchCimd?: (clientId: string) => Promise<Record<string, unknown>>;
22
+ /** Exchange a Google auth code at `${base}/callback` for tokens (+ owner email). */
23
+ exchangeCode?: (code: string, flow: StatePayload['flow']) => Promise<GoogleExchangeResult>;
24
+ /** Build the Google authorization URL (redirect back to `${base}/callback`).
25
+ * The impl chooses scope/prompt/login_hint from the flow: owner_gate =
26
+ * `openid email` + select_account; alias_reauth = the alias's resource scopes
27
+ * + consent. */
28
+ buildGoogleAuthUrl?: (opts: {
29
+ flow: StatePayload['flow'];
30
+ alias?: string;
31
+ state: string;
32
+ }) => string;
33
+ writeToken?: (alias: string, tokens: Record<string, unknown>) => void;
34
+ registeredClients?: Map<string, {
35
+ redirect_uris: string[];
36
+ }>;
37
+ replayGuard?: ReplayGuard;
38
+ refreshStore?: RefreshStore;
39
+ now?: () => number;
40
+ /** Server-side log (never sent to callers) for auth failures / SSRF blocks. */
41
+ log?: (line: string) => void;
42
+ /** The alias's configured Google email, for the alias_reauth identity binding. */
43
+ aliasEmail?: (alias: string) => string | undefined;
44
+ }
45
+ export interface AuthServer {
46
+ routes: Record<string, RouteHandler>;
47
+ authenticate: (req: IncomingMessage) => Promise<AuthOutcome>;
48
+ }
49
+ /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
50
+ * the fixed claude.ai callback. */
51
+ export declare function redirectAllowed(redirectUri: string, docRedirectUris: string[]): boolean;
52
+ export declare function buildAuthServer(config: AuthServerConfig, deps?: AuthServerDeps): AuthServer;
@@ -0,0 +1,457 @@
1
+ // B13: the minimal OAuth 2.1 Authorization Server + Resource Server
2
+ // (oauth-authorization-server.md). Federate-and-hold: the server mints its own
3
+ // audience-bound HS256 token for the MCP client (leg A), gates on an owner
4
+ // Google login (leg B, tokens discarded), and separately holds per-alias Google
5
+ // tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
6
+ // an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
7
+ // whole surface is exercised by an offline harness.
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { isIPv4 } from 'node:net';
10
+ import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
11
+ import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
12
+ const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
13
+ // DCR (/register) is public + unauthenticated, so its in-memory store must be
14
+ // bounded like every sibling store (ReplayGuard cap, RefreshStore SPENT_CAP) or
15
+ // a request loop OOMs the process. Cap client count (FIFO-evict the oldest) and
16
+ // the per-request redirect_uris shape so one entry can't be arbitrarily large.
17
+ export const DCR_MAX_CLIENTS = 1000;
18
+ export const DCR_MAX_REDIRECT_URIS = 10;
19
+ const DCR_MAX_URI_LEN = 2048;
20
+ // --- helpers ----------------------------------------------------------------
21
+ function json(res, status, body, extraHeaders = {}) {
22
+ res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
23
+ res.end(JSON.stringify(body));
24
+ return true;
25
+ }
26
+ function errorPage(res, status, slug, message) {
27
+ res.writeHead(status, { 'Content-Type': 'text/plain' });
28
+ res.end(`${slug}: ${message}`);
29
+ return true;
30
+ }
31
+ function redirect(res, location) {
32
+ res.writeHead(302, { Location: location });
33
+ res.end();
34
+ return true;
35
+ }
36
+ function isLoopbackHostname(h) {
37
+ const host = h.replace(/^\[|\]$/g, '').toLowerCase();
38
+ if (host === 'localhost' || host === '::1' || host === '::ffff:127.0.0.1')
39
+ return true;
40
+ // 127.0.0.0/8 — must be a genuine IPv4 literal in range, NOT merely a "127."
41
+ // prefix (which would let "127.evil.com" masquerade as loopback).
42
+ return isIPv4(host) && host.split('.')[0] === '127';
43
+ }
44
+ /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
45
+ * the fixed claude.ai callback. */
46
+ export function redirectAllowed(redirectUri, docRedirectUris) {
47
+ if (redirectUri === CLAUDE_AI_FIXED_CALLBACK)
48
+ return true;
49
+ if (docRedirectUris.includes(redirectUri))
50
+ return true;
51
+ let r;
52
+ try {
53
+ r = new URL(redirectUri);
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ if (!isLoopbackHostname(r.hostname))
59
+ return false; // non-loopback must be exact
60
+ return docRedirectUris.some((d) => {
61
+ let du;
62
+ try {
63
+ du = new URL(d);
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ // any loopback host + any port + same scheme + same path
69
+ return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
70
+ });
71
+ }
72
+ function pkceS256(verifier) {
73
+ return createHash('sha256').update(verifier).digest('base64url');
74
+ }
75
+ function readBody(req, maxBytes = 64 * 1024) {
76
+ return new Promise((resolve, reject) => {
77
+ let size = 0;
78
+ const chunks = [];
79
+ req.on('data', (c) => {
80
+ size += c.length;
81
+ if (size > maxBytes) {
82
+ reject(new Error('body too large'));
83
+ req.destroy();
84
+ return;
85
+ }
86
+ chunks.push(c);
87
+ });
88
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
89
+ req.on('error', reject);
90
+ });
91
+ }
92
+ function parseForm(raw, contentType) {
93
+ if (contentType?.includes('application/json')) {
94
+ try {
95
+ const obj = JSON.parse(raw);
96
+ const out = {};
97
+ for (const [k, v] of Object.entries(obj))
98
+ out[k] = String(v);
99
+ return out;
100
+ }
101
+ catch {
102
+ return {};
103
+ }
104
+ }
105
+ const out = {};
106
+ for (const [k, v] of new URLSearchParams(raw))
107
+ out[k] = v;
108
+ return out;
109
+ }
110
+ // --- the AS -----------------------------------------------------------------
111
+ export function buildAuthServer(config, deps = {}) {
112
+ const base = config.base;
113
+ const resource = config.resourceUri;
114
+ const secret = config.secret;
115
+ const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
116
+ const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
117
+ const replay = deps.replayGuard ?? new ReplayGuard();
118
+ const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey);
119
+ const registered = deps.registeredClients ?? new Map();
120
+ const now = deps.now ?? (() => Date.now());
121
+ const log = deps.log ?? (() => undefined);
122
+ const nowSec = () => Math.floor(now() / 1000);
123
+ const issHeader = { 'Cache-Control': 'no-store' };
124
+ const prm = {
125
+ resource,
126
+ authorization_servers: [base],
127
+ scopes_supported: ['mcp:use'],
128
+ bearer_methods_supported: ['header'],
129
+ };
130
+ const asMetadata = {
131
+ issuer: base,
132
+ authorization_endpoint: `${base}/authorize`,
133
+ token_endpoint: `${base}/token`,
134
+ registration_endpoint: `${base}/register`,
135
+ response_types_supported: ['code'],
136
+ grant_types_supported: ['authorization_code', 'refresh_token'],
137
+ code_challenge_methods_supported: ['S256'],
138
+ token_endpoint_auth_methods_supported: ['none'],
139
+ client_id_metadata_document_supported: true,
140
+ authorization_response_iss_parameter_supported: true,
141
+ scopes_supported: ['mcp:use'],
142
+ };
143
+ const cimdCache = new Map();
144
+ const CIMD_CACHE_TTL_MS = 5 * 60_000;
145
+ async function validateClient(clientId) {
146
+ const reg = registered.get(clientId);
147
+ if (reg)
148
+ return { redirect_uris: reg.redirect_uris, dcr: true };
149
+ // C7/#5: validate the issuer host + scheme BEFORE any outbound fetch, so we
150
+ // only ever fetch an allowlisted issuer (no arbitrary-URL SSRF surface).
151
+ let issuerHost;
152
+ let scheme;
153
+ try {
154
+ const u = new URL(clientId);
155
+ issuerHost = u.hostname;
156
+ scheme = u.protocol;
157
+ }
158
+ catch {
159
+ return { error: 'E_CIMD_INVALID', message: 'invalid client' };
160
+ }
161
+ if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
162
+ log(`CIMD rejected: client_id ${clientId} issuer not allowlisted`);
163
+ return { error: 'E_CIMD_INVALID', message: 'invalid client' };
164
+ }
165
+ let doc;
166
+ const cached = cimdCache.get(clientId);
167
+ if (cached && cached.exp > now()) {
168
+ doc = cached.doc;
169
+ }
170
+ else {
171
+ try {
172
+ doc = await cimd(clientId);
173
+ }
174
+ catch (e) {
175
+ // #13: never echo internal fetch details to the caller (blind-SSRF
176
+ // oracle); log server-side, return a generic message.
177
+ log(`CIMD fetch failed for ${clientId}: ${e.message}`);
178
+ const slug = e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID';
179
+ return { error: slug, message: 'invalid client' };
180
+ }
181
+ cimdCache.set(clientId, { doc, exp: now() + CIMD_CACHE_TTL_MS }); // #15
182
+ }
183
+ if (doc.client_id !== clientId)
184
+ return { error: 'E_CIMD_INVALID', message: 'invalid client' };
185
+ const uris = Array.isArray(doc.redirect_uris) ? doc.redirect_uris : [];
186
+ if (uris.length === 0)
187
+ return { error: 'E_CIMD_INVALID', message: 'invalid client' };
188
+ // C6 #14: warn on a localhost-only redirect set (loopback impersonation).
189
+ if (uris.every((u) => { try {
190
+ return isLoopbackHostname(new URL(u).hostname);
191
+ }
192
+ catch {
193
+ return false;
194
+ } })) {
195
+ log(`client ${clientId} advertises only loopback redirect_uris`);
196
+ }
197
+ return { redirect_uris: uris, dcr: false };
198
+ }
199
+ function escapeHtml(s) {
200
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
201
+ }
202
+ async function toGoogle(res, sp) {
203
+ const state = await signState(sp, base, secret, nowSec());
204
+ const authUrl = deps.buildGoogleAuthUrl
205
+ ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
206
+ : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
207
+ return redirect(res, authUrl);
208
+ }
209
+ // GET /authorize (validate + interstitial for DCR clients); POST /authorize
210
+ // (a DCR consent approval — verify the signed pending token, then proceed).
211
+ const authorize = async (req, res, url) => {
212
+ if (req.method === 'POST') {
213
+ let form;
214
+ try {
215
+ form = parseForm(await readBody(req), req.headers['content-type']);
216
+ }
217
+ catch {
218
+ return errorPage(res, 400, 'invalid_request', 'bad form');
219
+ }
220
+ let pending;
221
+ try {
222
+ pending = await verifyPending(form.pending ?? '', base, secret);
223
+ }
224
+ catch {
225
+ return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
226
+ }
227
+ if (!replay.consume(pending.jti, STATE_TTL_DEFAULT * 1000, now())) {
228
+ return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
229
+ }
230
+ const { jti: _drop, ...sp } = pending;
231
+ void _drop;
232
+ return toGoogle(res, sp);
233
+ }
234
+ const q = url.searchParams;
235
+ const clientId = q.get('client_id') ?? '';
236
+ const redirectUri = q.get('redirect_uri') ?? '';
237
+ const codeChallenge = q.get('code_challenge') ?? '';
238
+ const method = q.get('code_challenge_method') ?? '';
239
+ const resourceParam = q.get('resource') ?? '';
240
+ const clientState = q.get('state') ?? undefined;
241
+ const aliasParam = q.get('alias') ?? undefined;
242
+ // In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
243
+ // client leg — no code is delivered to any client, the refreshed token is
244
+ // written server-side for the alias — so it skips the client_id/redirect/
245
+ // PKCE requirements. Safe because /callback binds the completing Google
246
+ // identity to the alias's configured email before writing anything.
247
+ if (q.get('flow') === 'alias_reauth' && aliasParam && !clientId) {
248
+ if (!deps.aliasEmail?.(aliasParam)) {
249
+ return errorPage(res, 400, 'invalid_request', `unknown account "${aliasParam}"`);
250
+ }
251
+ return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias: aliasParam });
252
+ }
253
+ if (!clientId)
254
+ return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
255
+ const client = await validateClient(clientId);
256
+ if ('error' in client)
257
+ return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
258
+ // (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
259
+ if (!redirectUri || !redirectAllowed(redirectUri, client.redirect_uris)) {
260
+ return errorPage(res, 400, 'E_REDIRECT_URI_MISMATCH', 'redirect_uri is not an allowed callback for this client');
261
+ }
262
+ // (3) PKCE S256 required
263
+ if (!codeChallenge || method !== 'S256') {
264
+ return json(res, 400, { error: 'invalid_request', message: 'code_challenge with S256 required', iss: base }, issHeader);
265
+ }
266
+ // (4) resource must be the canonical resource URI
267
+ if (resourceParam !== resource) {
268
+ return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
269
+ }
270
+ // (5) only after full validation do we build the request artifact.
271
+ const statePayload = {
272
+ flow: q.get('flow') === 'alias_reauth' ? 'alias_reauth' : 'owner_gate',
273
+ client_id: clientId,
274
+ redirect_uri: redirectUri,
275
+ code_challenge: codeChallenge,
276
+ client_state: clientState,
277
+ resource,
278
+ alias: q.get('alias') ?? undefined,
279
+ };
280
+ // Confused-deputy defense (#3): a self-registered DCR client can pick an
281
+ // arbitrary redirect_uri, so before we send the owner to Google (whose
282
+ // consent screen names only THIS server, not the requesting app), show an
283
+ // approval interstitial naming the client + destination. CIMD clients skip
284
+ // it — their redirect_uris come from an issuer-allowlisted document a rogue
285
+ // cannot forge, so there is nothing to spoof.
286
+ if (client.dcr) {
287
+ const pendingToken = await signPending(statePayload, base, secret, nowSec());
288
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
289
+ res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
290
+ `<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
291
+ `<h2>Authorize access?</h2>` +
292
+ `<p>A newly-registered application (<code>${escapeHtml(clientId)}</code>) is requesting access to your Google Workspace through this server.</p>` +
293
+ `<p>If approved, it will receive an authorization redirected to:<br><code>${escapeHtml(redirectUri)}</code></p>` +
294
+ `<p><strong>If you did not start this, close this window.</strong></p>` +
295
+ `<form method="POST" action="/authorize"><input type="hidden" name="pending" value="${escapeHtml(pendingToken)}">` +
296
+ `<button type="submit" style="font-size:1em;padding:.5em 1.5em">Approve</button></form></body>`);
297
+ return true;
298
+ }
299
+ return toGoogle(res, statePayload);
300
+ };
301
+ // GET /callback
302
+ const callback = async (req, res, url) => {
303
+ const code = url.searchParams.get('code') ?? '';
304
+ const stateToken = url.searchParams.get('state') ?? '';
305
+ let st;
306
+ try {
307
+ st = await verifyState(stateToken, base, secret);
308
+ }
309
+ catch {
310
+ return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
311
+ }
312
+ if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
313
+ return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
314
+ }
315
+ if (!code)
316
+ return errorPage(res, 400, 'invalid_request', 'missing code');
317
+ let exchanged;
318
+ try {
319
+ exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
320
+ }
321
+ catch (e) {
322
+ return errorPage(res, 400, 'invalid_grant', `Google code exchange failed: ${e.message}`);
323
+ }
324
+ if (st.flow === 'alias_reauth') {
325
+ if (!st.alias)
326
+ return errorPage(res, 400, 'invalid_request', 'alias_reauth without an alias');
327
+ // C13 / identity binding (#2/#6): only overwrite the alias's tokens if the
328
+ // completing Google identity IS that alias's configured account — else an
329
+ // attacker who crafts an alias_reauth link and logs in with their OWN
330
+ // account could inject their tokens (account swap). The alias emails (leg
331
+ // C) are distinct from MCP_OWNER_EMAILS (leg B), so bind to the alias.
332
+ const gotEmail = (exchanged.email ?? '').toLowerCase();
333
+ const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
334
+ if (!expected || !gotEmail || gotEmail !== expected) {
335
+ log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
336
+ return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
337
+ }
338
+ deps.writeToken?.(st.alias, exchanged.tokens);
339
+ log(`callback ok flow=alias_reauth alias=${st.alias}`);
340
+ res.writeHead(200, { 'Content-Type': 'text/html' });
341
+ res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${st.alias}". You can close this window.</p>`);
342
+ return true;
343
+ }
344
+ // owner_gate: verify the email is an allowlisted owner, discard Google tokens
345
+ const email = (exchanged.email ?? '').toLowerCase();
346
+ const iss = encodeURIComponent(base);
347
+ if (!email || !config.ownerEmails.includes(email)) {
348
+ const sep = st.redirect_uri.includes('?') ? '&' : '?';
349
+ const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
350
+ return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
351
+ }
352
+ const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: 'owner' }, base, secret, nowSec());
353
+ log('callback ok flow=owner_gate');
354
+ const sep = st.redirect_uri.includes('?') ? '&' : '?';
355
+ const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
356
+ return redirect(res, `${st.redirect_uri}${sep}code=${encodeURIComponent(authzCode)}${s}&iss=${iss}`);
357
+ };
358
+ // POST /token
359
+ const token = async (req, res) => {
360
+ let form;
361
+ try {
362
+ form = parseForm(await readBody(req), req.headers['content-type']);
363
+ }
364
+ catch {
365
+ return json(res, 400, { error: 'invalid_request', message: 'bad body' });
366
+ }
367
+ const grant = form.grant_type;
368
+ if (grant === 'authorization_code') {
369
+ let code;
370
+ try {
371
+ code = await verifyAuthzCode(form.code ?? '', base, secret);
372
+ }
373
+ catch {
374
+ return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
375
+ }
376
+ if (!replay.consume(code.jti, CODE_TTL_DEFAULT * 1000, now())) {
377
+ return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
378
+ }
379
+ if (form.redirect_uri !== code.redirect_uri) {
380
+ return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
381
+ }
382
+ if (form.resource && form.resource !== code.resource) {
383
+ return json(res, 400, { error: 'invalid_grant', message: 'resource mismatch' });
384
+ }
385
+ if (!form.code_verifier || pkceS256(form.code_verifier) !== code.code_challenge) {
386
+ return json(res, 400, { error: 'invalid_grant', message: 'PKCE verification failed' });
387
+ }
388
+ const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
389
+ const refreshToken = refresh.issue(now());
390
+ log('token issued grant=authorization_code');
391
+ return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
392
+ }
393
+ if (grant === 'refresh_token') {
394
+ const next = refresh.rotate(form.refresh_token ?? '', now());
395
+ if (!next)
396
+ return json(res, 400, { error: 'invalid_grant', message: 'unknown or rotated refresh token' });
397
+ const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
398
+ log('token issued grant=refresh_token');
399
+ return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next, scope: 'mcp:use' });
400
+ }
401
+ return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
402
+ };
403
+ // POST /register (minimal DCR, D2). Parse the raw JSON body directly (the
404
+ // redirect_uris array must survive intact).
405
+ const register = async (req, res) => {
406
+ let parsed;
407
+ try {
408
+ parsed = JSON.parse(await readBody(req));
409
+ }
410
+ catch {
411
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
412
+ }
413
+ const redirectUris = Array.isArray(parsed.redirect_uris) ? parsed.redirect_uris.map(String) : [];
414
+ if (redirectUris.length === 0)
415
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
416
+ if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
417
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
418
+ }
419
+ // Bound the store: FIFO-evict the oldest registration once at capacity (Map
420
+ // preserves insertion order) so a /register flood can't grow RSS unbounded.
421
+ while (registered.size >= DCR_MAX_CLIENTS) {
422
+ const oldest = registered.keys().next().value;
423
+ if (oldest === undefined)
424
+ break;
425
+ registered.delete(oldest);
426
+ }
427
+ const clientId = `mcpb-${randomBytes(16).toString('hex')}`;
428
+ registered.set(clientId, { redirect_uris: redirectUris });
429
+ return json(res, 201, { client_id: clientId, redirect_uris: redirectUris, token_endpoint_auth_method: 'none' });
430
+ };
431
+ const wellKnownPrm = (req, res) => json(res, 200, prm);
432
+ const wellKnownAs = (req, res) => json(res, 200, asMetadata);
433
+ const routes = {
434
+ '/.well-known/oauth-protected-resource': wellKnownPrm,
435
+ '/.well-known/oauth-protected-resource/mcp': wellKnownPrm,
436
+ '/.well-known/oauth-authorization-server': wellKnownAs,
437
+ '/authorize': authorize,
438
+ '/callback': callback,
439
+ '/token': token,
440
+ '/register': register,
441
+ };
442
+ const wwwAuth = `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource", scope="mcp:use"`;
443
+ const authenticate = async (req) => {
444
+ const header = req.headers.authorization;
445
+ if (!header || !header.startsWith('Bearer ')) {
446
+ return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'missing bearer token' }) };
447
+ }
448
+ try {
449
+ await verifyAccessToken(header.slice(7), base, secret);
450
+ return { ok: true };
451
+ }
452
+ catch {
453
+ return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
454
+ }
455
+ };
456
+ return { routes, authenticate };
457
+ }
@@ -0,0 +1,28 @@
1
+ import { OAuth2Client } from 'googleapis-common';
2
+ export declare const LOOPBACK_PORT = 4242;
3
+ export declare const LOOPBACK_REDIRECT = "http://localhost:4242/oauth2callback";
4
+ /** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
5
+ export declare class ClientCredentialsMissingError extends Error {
6
+ constructor();
7
+ }
8
+ export declare class LoopbackPortInUseError extends Error {
9
+ constructor();
10
+ }
11
+ export declare class ConsentTimeoutError extends Error {
12
+ constructor();
13
+ }
14
+ export declare class ConsentDeniedError extends Error {
15
+ constructor(reason: string);
16
+ }
17
+ export declare function hasClientCredentials(): boolean;
18
+ /** Build the loopback OAuth2 client from env credentials (throws if unset). */
19
+ export declare function buildConsentClient(): OAuth2Client;
20
+ /**
21
+ * Start the loopback server, await the OAuth redirect, validate the CSRF
22
+ * `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
23
+ * the caller to persist. Never process.exit's (safe inside a live server) and
24
+ * times out so a stalled consent can't wedge a tool call.
25
+ */
26
+ export declare function awaitLoopbackConsent(client: OAuth2Client, expectedState: string, opts?: {
27
+ timeoutMs?: number;
28
+ }): Promise<Record<string, unknown>>;
@@ -0,0 +1,109 @@
1
+ import { OAuth2Client } from 'googleapis-common';
2
+ import http from 'node:http';
3
+ import { URL } from 'node:url';
4
+ // Shared loopback OAuth consent (leg C of cc-auth), used by the account wizard
5
+ // (B7). The `auth --account` CLI keeps its own inline copy for now; this module
6
+ // is the reusable, live-server-safe form (throws typed errors instead of
7
+ // process.exit, and times out so a never-completed consent can't wedge a tool
8
+ // call forever). The redirect matches the CLI exactly so an existing registered
9
+ // Desktop client keeps working.
10
+ export const LOOPBACK_PORT = 4242;
11
+ export const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/oauth2callback`;
12
+ /** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
13
+ export class ClientCredentialsMissingError extends Error {
14
+ constructor() {
15
+ super('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set.');
16
+ }
17
+ }
18
+ export class LoopbackPortInUseError extends Error {
19
+ constructor() {
20
+ super(`E_LOOPBACK_PORT_IN_USE: port ${LOOPBACK_PORT} is already in use; close the other process and retry.`);
21
+ }
22
+ }
23
+ export class ConsentTimeoutError extends Error {
24
+ constructor() {
25
+ super('E_CONSENT_TIMEOUT: no OAuth redirect arrived before the timeout.');
26
+ }
27
+ }
28
+ export class ConsentDeniedError extends Error {
29
+ constructor(reason) {
30
+ super(`E_CONSENT_DENIED: ${reason}`);
31
+ }
32
+ }
33
+ export function hasClientCredentials() {
34
+ return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
35
+ }
36
+ /** Build the loopback OAuth2 client from env credentials (throws if unset). */
37
+ export function buildConsentClient() {
38
+ if (!hasClientCredentials())
39
+ throw new ClientCredentialsMissingError();
40
+ return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, LOOPBACK_REDIRECT);
41
+ }
42
+ /**
43
+ * Start the loopback server, await the OAuth redirect, validate the CSRF
44
+ * `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
45
+ * the caller to persist. Never process.exit's (safe inside a live server) and
46
+ * times out so a stalled consent can't wedge a tool call.
47
+ */
48
+ export function awaitLoopbackConsent(client, expectedState, opts = {}) {
49
+ const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
50
+ return new Promise((resolve, reject) => {
51
+ let timer;
52
+ const server = http.createServer(async (req, res) => {
53
+ if (!req.url || !req.url.startsWith('/oauth2callback')) {
54
+ res.writeHead(404).end();
55
+ return;
56
+ }
57
+ const done = (code, body) => {
58
+ res.writeHead(code, { 'Content-Type': 'text/html' });
59
+ res.end(body);
60
+ if (timer)
61
+ clearTimeout(timer);
62
+ server.close();
63
+ server.closeAllConnections();
64
+ };
65
+ try {
66
+ const qs = new URL(req.url, LOOPBACK_REDIRECT).searchParams;
67
+ const error = qs.get('error');
68
+ if (error) {
69
+ done(400, `<p>Authorization denied: ${error}</p>`);
70
+ reject(new ConsentDeniedError(error));
71
+ return;
72
+ }
73
+ const returnedState = qs.get('state');
74
+ if (returnedState !== expectedState) {
75
+ done(400, '<p>State mismatch — possible CSRF attempt. Aborting.</p>');
76
+ reject(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
77
+ return;
78
+ }
79
+ const code = qs.get('code');
80
+ if (!code) {
81
+ done(400, '<p>No authorization code received.</p>');
82
+ reject(new ConsentDeniedError('no authorization code received'));
83
+ return;
84
+ }
85
+ const { tokens } = await client.getToken(code);
86
+ done(200, '<h2>Authentication successful!</h2><p>You can close this tab.</p>');
87
+ resolve(tokens);
88
+ }
89
+ catch (e) {
90
+ done(500, '<p>Internal error during authentication.</p>');
91
+ reject(e);
92
+ }
93
+ });
94
+ server.on('error', (err) => {
95
+ if (timer)
96
+ clearTimeout(timer);
97
+ reject(err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err);
98
+ });
99
+ server.listen(LOOPBACK_PORT, '127.0.0.1', () => {
100
+ timer = setTimeout(() => {
101
+ server.close();
102
+ server.closeAllConnections();
103
+ reject(new ConsentTimeoutError());
104
+ }, timeoutMs);
105
+ // unref so a pending consent never keeps the process alive on its own.
106
+ timer.unref?.();
107
+ });
108
+ });
109
+ }
@@ -0,0 +1 @@
1
+ export declare function openUrl(url: string): void;