mcp-google-multi 6.0.0-alpha.55 → 6.0.0-alpha.56

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/dist/accounts.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import { z } from 'zod';
4
4
  import { loadEnvFiles } from './env-load.js';
5
5
  import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
6
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
6
+ import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
7
7
  const envLoad = loadEnvFiles();
8
8
  const defaultTokenDir = path.join(configDir(), 'tokens');
9
9
  const tokenDir = process.env.TOKEN_STORE_PATH
@@ -95,7 +95,7 @@ opts = {}) {
95
95
  if (bundle === 'admin') {
96
96
  fail('E_UNKNOWN_BUNDLE', '"admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
97
97
  }
98
- if (!(bundle in BUNDLE_CATALOG)) {
98
+ if (!isKnownBundle(bundle)) {
99
99
  const hint = closestBundle(bundle);
100
100
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
101
101
  }
@@ -152,7 +152,7 @@ opts = {}) {
152
152
  for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
153
153
  const bundles = resolveBundleAliases(profile.bundles);
154
154
  for (const bundle of bundles) {
155
- if (!(bundle in BUNDLE_CATALOG)) {
155
+ if (!isKnownBundle(bundle)) {
156
156
  const hint = closestBundle(bundle);
157
157
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
158
158
  }
package/dist/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { openUrl } from './open-url.js';
3
3
  import { ACCOUNTS, getAccountSet } from './accounts.js';
4
- import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
4
+ import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
5
5
  import { resolveMasterKey } from './master-key.js';
6
6
  import { writeToken } from './token-store.js';
7
7
  import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
@@ -48,7 +48,7 @@ function legacyGlobalProfile() {
48
48
  if (bundle === 'admin') {
49
49
  throw new Error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
50
50
  }
51
- if (!(bundle in BUNDLE_CATALOG)) {
51
+ if (!isKnownBundle(bundle)) {
52
52
  const hint = closestBundle(bundle);
53
53
  throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
54
54
  }
package/dist/index.js CHANGED
@@ -246,7 +246,7 @@ async function main() {
246
246
  registry.installListHandler();
247
247
  registerSetupPrompt(httpServer);
248
248
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
249
- const { buildAuthServer } = await import('./oauth-as.js');
249
+ const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
250
250
  const { jwtSecretFrom } = await import('./mcp-token.js');
251
251
  const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
252
252
  const { OAuth2Client } = await import('googleapis-common');
@@ -255,20 +255,6 @@ async function main() {
255
255
  const { configDir } = await import('./config-file.js');
256
256
  const { getAccountSet } = await import('./accounts.js');
257
257
  const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
258
- // Only trust the id_token email when Google marks it verified (#8).
259
- const verifiedEmailFromIdToken = (idToken) => {
260
- if (!idToken)
261
- return undefined;
262
- try {
263
- const [, payload] = idToken.split('.');
264
- const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
265
- const verified = claims.email_verified === true || claims.email_verified === 'true';
266
- return verified ? claims.email : undefined;
267
- }
268
- catch {
269
- return undefined;
270
- }
271
- };
272
258
  const authServer = buildAuthServer({
273
259
  base: httpCfg.publicUrl,
274
260
  resourceUri: httpCfg.resourceUri,
@@ -309,8 +295,7 @@ async function main() {
309
295
  const { setHttpReauthBase } = await import('./reauth-hint.js');
310
296
  setHttpReauthBase(httpCfg.publicUrl);
311
297
  // Wizard consent over HTTP: hand out the clientless AS link instead of
312
- // binding a loopback listener (single-owner flow; a tenancy host installs
313
- // its own signed-mint context here).
298
+ // binding a loopback listener.
314
299
  const { setWizardHttpConsent } = await import('./tools/account-wizard.js');
315
300
  setWizardHttpConsent({
316
301
  mintConsentUrl: (alias) => `${httpCfg.publicUrl}/authorize?flow=alias_reauth&alias=${encodeURIComponent(alias)}`,
@@ -32,6 +32,9 @@ export interface StatePayload {
32
32
  tenantId?: string;
33
33
  /** alias_add only: scope bundles chosen when the link was minted. */
34
34
  bundles?: string[];
35
+ /** alias_add only: an opaque value the minting caller chose, signed like
36
+ * tenantId and handed back to its binder (e.g. a server-side link record). */
37
+ nonce?: string;
35
38
  }
36
39
  export interface CodePayload {
37
40
  redirect_uri: string;
@@ -1,5 +1,5 @@
1
1
  import { CONFIG_VERSION, configFilePath, loadConfigFile, mutateConfigFile } from './config-file.js';
2
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
2
+ import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
3
3
  function parseCsv(value) {
4
4
  return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
5
5
  }
@@ -47,7 +47,7 @@ export function runMigrateConfig(env = process.env) {
47
47
  process.exitCode = 1;
48
48
  return;
49
49
  }
50
- if (!(bundle in BUNDLE_CATALOG)) {
50
+ if (!isKnownBundle(bundle)) {
51
51
  const hint = closestBundle(bundle);
52
52
  console.error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}. Nothing written.`);
53
53
  process.exitCode = 1;
@@ -22,6 +22,27 @@ export interface AuthServerConfig {
22
22
  * cross-tenant re-auth vector. Server-minted signed flows are unaffected. */
23
23
  legacyAliasReauth?: boolean;
24
24
  }
25
+ /** What /callback hands the alias_add binder once Google returned a verified
26
+ * identity for a server-minted link. */
27
+ export interface TenantAliasBind {
28
+ tenantId: string;
29
+ alias: string;
30
+ email: string;
31
+ bundles?: string[];
32
+ nonce?: string;
33
+ tokens: Record<string, unknown>;
34
+ }
35
+ /** A binder's deliberate refusal (policy, not failure): /callback answers 403
36
+ * with this slug and message. The message reaches an unauthenticated browser. */
37
+ export interface AliasBindRefusal {
38
+ refused: {
39
+ slug: string;
40
+ message: string;
41
+ };
42
+ }
43
+ /** Only trust the id_token email when Google marks it verified (#8). The token
44
+ * comes straight from Google's token endpoint, so its claims are read unsigned. */
45
+ export declare function verifiedEmailFromIdToken(idToken?: string | null): string | undefined;
25
46
  export interface AuthServerDeps {
26
47
  fetchCimd?: (clientId: string) => Promise<Record<string, unknown>>;
27
48
  /** Exchange a Google auth code at `${base}/callback` for tokens (+ owner email). */
@@ -34,6 +55,7 @@ export interface AuthServerDeps {
34
55
  flow: StatePayload['flow'];
35
56
  alias?: string;
36
57
  state: string;
58
+ bundles?: string[];
37
59
  }) => string;
38
60
  writeToken?: (alias: string, tokens: Record<string, unknown>) => void;
39
61
  registeredClients?: Map<string, {
@@ -52,16 +74,11 @@ export interface AuthServerDeps {
52
74
  resolveSubject?: (email: string) => {
53
75
  sub: string;
54
76
  } | null;
55
- /** alias_add completion: persist the config row AND the tokens for the new
56
- * alias under its tenant in ONE call, atomically — /callback invokes it only
57
- * after a successful Google exchange, so no pre-write orphan can exist. */
58
- bindTenantAlias?: (bind: {
59
- tenantId: string;
60
- alias: string;
61
- email: string;
62
- bundles?: string[];
63
- tokens: Record<string, unknown>;
64
- }) => void;
77
+ /** alias_add completion: persist the new alias's config row and tokens under
78
+ * its tenant. /callback awaits it only after a successful Google exchange.
79
+ * A returned refusal answers 403 with its slug; a throw or rejection answers
80
+ * 500 E_ALIAS_ADD_FAILED. */
81
+ bindTenantAlias?: (bind: TenantAliasBind) => void | AliasBindRefusal | Promise<void | AliasBindRefusal>;
65
82
  }
66
83
  export interface AuthServer {
67
84
  routes: Record<string, RouteHandler>;
@@ -69,12 +86,13 @@ export interface AuthServer {
69
86
  /** Mint a signed, single-use, TTL-bound clientless flow URL
70
87
  * (`${base}/authorize?flow=...&state=<signed>`). The tenantId/bundles ride
71
88
  * INSIDE the signed state, so nothing at /authorize or /callback trusts a
72
- * caller-supplied identity. Inert unless something calls it (EE wizard). */
89
+ * caller-supplied identity. Inert unless something calls it. */
73
90
  mintFlowState: (opts: {
74
91
  flow: 'alias_add' | 'alias_reauth';
75
92
  alias: string;
76
93
  tenantId?: string;
77
94
  bundles?: string[];
95
+ nonce?: string;
78
96
  }) => Promise<string>;
79
97
  }
80
98
  /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
package/dist/oauth-as.js CHANGED
@@ -17,6 +17,21 @@ const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
17
17
  export const DCR_MAX_CLIENTS = 1000;
18
18
  export const DCR_MAX_REDIRECT_URIS = 10;
19
19
  const DCR_MAX_URI_LEN = 2048;
20
+ /** Only trust the id_token email when Google marks it verified (#8). The token
21
+ * comes straight from Google's token endpoint, so its claims are read unsigned. */
22
+ export function verifiedEmailFromIdToken(idToken) {
23
+ if (!idToken)
24
+ return undefined;
25
+ try {
26
+ const [, payload] = idToken.split('.');
27
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
28
+ const verified = claims.email_verified === true || claims.email_verified === 'true';
29
+ return verified ? claims.email : undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
20
35
  // --- helpers ----------------------------------------------------------------
21
36
  function json(res, status, body, extraHeaders = {}) {
22
37
  res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
@@ -203,7 +218,7 @@ export function buildAuthServer(config, deps = {}) {
203
218
  async function toGoogle(res, sp) {
204
219
  const state = await signState(sp, base, secret, nowSec());
205
220
  const authUrl = deps.buildGoogleAuthUrl
206
- ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
221
+ ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
207
222
  : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
208
223
  return redirect(res, authUrl);
209
224
  }
@@ -356,16 +371,26 @@ export function buildAuthServer(config, deps = {}) {
356
371
  const boundEmail = (exchanged.email ?? '').toLowerCase();
357
372
  if (!boundEmail)
358
373
  return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
374
+ let refusal;
359
375
  try {
360
- deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, tokens: exchanged.tokens });
376
+ const out = await deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, nonce: st.nonce, tokens: exchanged.tokens });
377
+ refusal = out?.refused;
361
378
  }
362
379
  catch (e) {
363
- log(`alias_add bind failed for "${st.alias}": ${e.message}`);
380
+ // Any rejection reason, even none, must still reach this answer.
381
+ log(`alias_add bind failed for "${st.alias}": ${e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection'}`);
364
382
  return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
365
383
  }
384
+ if (refusal) {
385
+ // The slug lands in a text/plain body; keep it a bare identifier.
386
+ const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
387
+ const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
388
+ log(`alias_add refused for "${st.alias}": ${slug}`);
389
+ return errorPage(res, 403, slug, message);
390
+ }
366
391
  log(`callback ok flow=alias_add alias=${st.alias}`);
367
392
  res.writeHead(200, { 'Content-Type': 'text/html' });
368
- res.end(`<!doctype html><meta charset=utf-8><p>Connected "${st.alias}". You can close this window and retry your request.</p>`);
393
+ res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
369
394
  return true;
370
395
  }
371
396
  if (st.flow === 'alias_reauth') {
@@ -385,7 +410,7 @@ export function buildAuthServer(config, deps = {}) {
385
410
  deps.writeToken?.(st.alias, exchanged.tokens);
386
411
  log(`callback ok flow=alias_reauth alias=${st.alias}`);
387
412
  res.writeHead(200, { 'Content-Type': 'text/html' });
388
- res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${st.alias}". You can close this window.</p>`);
413
+ res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>`);
389
414
  return true;
390
415
  }
391
416
  // owner_gate: resolve the verified email to its subject (default = the
@@ -503,8 +528,8 @@ export function buildAuthServer(config, deps = {}) {
503
528
  return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
504
529
  }
505
530
  };
506
- const mintFlowState = async ({ flow, alias, tenantId, bundles }) => {
507
- const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles }, base, secret, nowSec());
531
+ const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
532
+ const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, secret, nowSec());
508
533
  return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
509
534
  };
510
535
  return { routes, authenticate, mintFlowState };
@@ -14,6 +14,9 @@ export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
14
14
  /** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
15
15
  * future rename is a deprecation warning, never a breaking config change. */
16
16
  export declare const BUNDLE_ALIASES: Record<string, string>;
17
+ /** Bundle names are user input: an Object.prototype member ("constructor",
18
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
19
+ export declare function isKnownBundle(name: string): boolean;
17
20
  export declare function resolveBundleAliases(bundles: string[]): string[];
18
21
  /** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
19
22
  export declare function closestBundle(name: string): string | undefined;
@@ -160,9 +160,14 @@ export const BUNDLE_CATALOG = {
160
160
  * future rename is a deprecation warning, never a breaking config change. */
161
161
  export const BUNDLE_ALIASES = {};
162
162
  const warnedAliases = new Set();
163
+ /** Bundle names are user input: an Object.prototype member ("constructor",
164
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
165
+ export function isKnownBundle(name) {
166
+ return Object.hasOwn(BUNDLE_CATALOG, name);
167
+ }
163
168
  export function resolveBundleAliases(bundles) {
164
169
  return bundles.map((name) => {
165
- const target = BUNDLE_ALIASES[name];
170
+ const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
166
171
  if (!target)
167
172
  return name;
168
173
  if (!warnedAliases.has(name)) {
@@ -57,7 +57,7 @@ export declare function scopeGrantDiff(requested: string[], grantedScope: string
57
57
  * present, runConsent NEVER binds a server-side loopback listener — the
58
58
  * user's browser cannot reach the daemon's loopback — and instead hands out
59
59
  * a clientless AS consent URL. The injector decides the flow (single-owner =
60
- * the legacy alias_reauth link; a tenancy host mints a signed alias_add URL). */
60
+ * the legacy alias_reauth link). */
61
61
  export interface WizardHttpConsent {
62
62
  mintConsentUrl: (alias: string) => Promise<string> | string;
63
63
  }
@@ -3,7 +3,7 @@ import { getAccountSet, invalidateAccountSet } from '../accounts.js';
3
3
  import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
4
4
  import { writeToken } from '../token-store.js';
5
5
  import { resolveScopesForAccount } from '../auth.js';
6
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
6
+ import { BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from '../scope-catalog.js';
7
7
  import { openUrl } from '../open-url.js';
8
8
  import { coerceBoolean } from './_coerce.js';
9
9
  import { safeMessage, stringifyEnvelope } from './_errors.js';
@@ -58,7 +58,7 @@ export function validateAddForm(input, existingAliases) {
58
58
  if (b === 'admin') {
59
59
  return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: '"admin" is not a scope bundle.', hint: 'Use the admin checkbox instead, or pass "admin": true.', alias };
60
60
  }
61
- if (!(b in BUNDLE_CATALOG)) {
61
+ if (!isKnownBundle(b)) {
62
62
  const closest = closestBundle(b);
63
63
  const known = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ');
64
64
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.55",
3
+ "version": "6.0.0-alpha.56",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",
@@ -28,6 +28,8 @@
28
28
  "./tenant-purge": "./dist/tenant-purge.js",
29
29
  "./doctor": "./dist/doctor.js",
30
30
  "./write-control": "./dist/write-control.js",
31
+ "./scope-catalog": "./dist/scope-catalog.js",
32
+ "./auth": "./dist/auth.js",
31
33
  "./package.json": "./package.json"
32
34
  },
33
35
  "files": [