mcp-google-multi 6.0.0-alpha.57 → 6.0.0-alpha.58

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/index.js CHANGED
@@ -250,9 +250,10 @@ async function main() {
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');
253
- const { writeToken } = await import('./token-store.js');
253
+ const { readToken, writeToken } = await import('./token-store.js');
254
254
  const { resolveScopesForAccount } = await import('./auth.js');
255
255
  const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
256
+ const { scopeGrantDiff, setWizardHttpConsent } = await import('./tools/account-wizard.js');
256
257
  const { configDir } = await import('./config-file.js');
257
258
  const { getAccountSet } = await import('./accounts.js');
258
259
  const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
@@ -275,19 +276,25 @@ async function main() {
275
276
  return { tokens: flow === 'owner_gate' ? {} : tokens, email };
276
277
  },
277
278
  writeToken: (alias, tokens) => writeToken(alias, tokens),
279
+ missingScopes: (alias, granted) => scopeGrantDiff(resolveScopesForAccount(alias), granted),
280
+ hasToken: (alias) => {
281
+ try {
282
+ return readToken(alias) != null;
283
+ }
284
+ catch {
285
+ return false; // undecryptable: nothing usable to keep
286
+ }
287
+ },
278
288
  aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
279
289
  log: (l) => process.stderr.write(`[as] ${l}\n`),
280
290
  });
281
- // BV-1: over HTTP, a dead/missing per-account token surfaces a clickable
282
- // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
283
- const { setHttpReauthBase } = await import('./reauth-hint.js');
284
- setHttpReauthBase(httpCfg.publicUrl);
285
- // Wizard consent over HTTP: hand out the clientless AS link instead of
291
+ // BV-1: over HTTP, a dead/missing per-account token surfaces a clickable,
292
+ // signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
293
+ const { setHttpReauthLink } = await import('./reauth-hint.js');
294
+ setHttpReauthLink((alias) => authServer.reauthLink(alias));
295
+ // Wizard consent over HTTP: hand out the same signed link instead of
286
296
  // binding a loopback listener.
287
- const { setWizardHttpConsent } = await import('./tools/account-wizard.js');
288
- setWizardHttpConsent({
289
- mintConsentUrl: (alias) => `${httpCfg.publicUrl}/authorize?flow=alias_reauth&alias=${encodeURIComponent(alias)}`,
290
- });
297
+ setWizardHttpConsent({ mintConsentUrl: (alias) => authServer.reauthLink(alias) });
291
298
  let httpTap;
292
299
  if (httpMetrics) {
293
300
  const { tapUsageMetrics } = await import('./metrics-tap.js');
@@ -46,6 +46,18 @@ export declare function signState(payload: StatePayload, base: string, secret: U
46
46
  export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
47
47
  jti: string;
48
48
  }>;
49
+ /** A re-auth link is handed out in a tool error, so it has to survive until
50
+ * the user clicks it; an hour, not the 10-minute state TTL. */
51
+ export declare const REAUTH_LINK_TTL_SEC = 3600;
52
+ /** Query string of a server-issued alias_reauth link: the alias, an expiry and
53
+ * an HMAC over both. Synchronous, so the error hints that carry it stay so. */
54
+ export declare function signReauthLink(base: string, secret: Uint8Array, alias: string, nowSec: number): string;
55
+ /** The alias a link was issued for, or null when it is missing, forged or expired. */
56
+ export declare function verifyReauthLink(base: string, secret: Uint8Array, params: {
57
+ alias: string;
58
+ exp: string;
59
+ sig: string;
60
+ }, nowSec: number): string | null;
49
61
  /** A pending-authorization artifact for the DCR consent interstitial (same
50
62
  * shape as `state`, distinct purpose so the two can't be confused). */
51
63
  export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
package/dist/mcp-token.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // `state` and authorization code, the in-memory single-use replay guard, and
4
4
  // the opaque rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
5
5
  // (B5), so tokens survive a restart as long as the key persists.
6
- import { randomBytes } from 'node:crypto';
6
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
8
  import { SignJWT, jwtVerify } from 'jose';
9
9
  import { deriveKey, encryptToken, decryptToken } from './token-store.js';
@@ -59,6 +59,30 @@ export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT
59
59
  export async function verifyState(token, base, secret) {
60
60
  return (await verifyArtifact(token, 'mcp_state', base, secret));
61
61
  }
62
+ // --- alias_reauth link -------------------------------------------------------
63
+ /** A re-auth link is handed out in a tool error, so it has to survive until
64
+ * the user clicks it; an hour, not the 10-minute state TTL. */
65
+ export const REAUTH_LINK_TTL_SEC = 3600;
66
+ function reauthMac(base, secret, alias, exp) {
67
+ // The newline-separated input can never be a JWT signing input (two
68
+ // base64url segments and one dot), so the shared key stays unambiguous.
69
+ return createHmac('sha256', secret).update(`alias_reauth\n${base}\n${alias}\n${exp}`).digest();
70
+ }
71
+ /** Query string of a server-issued alias_reauth link: the alias, an expiry and
72
+ * an HMAC over both. Synchronous, so the error hints that carry it stay so. */
73
+ export function signReauthLink(base, secret, alias, nowSec) {
74
+ const exp = nowSec + REAUTH_LINK_TTL_SEC;
75
+ return `alias=${encodeURIComponent(alias)}&exp=${exp}&sig=${reauthMac(base, secret, alias, exp).toString('base64url')}`;
76
+ }
77
+ /** The alias a link was issued for, or null when it is missing, forged or expired. */
78
+ export function verifyReauthLink(base, secret, params, nowSec) {
79
+ const exp = Number(params.exp);
80
+ if (!params.alias || !/^\d+$/.test(params.exp) || exp < nowSec)
81
+ return null;
82
+ const want = reauthMac(base, secret, params.alias, exp);
83
+ const got = Buffer.from(params.sig, 'base64url');
84
+ return got.length === want.length && timingSafeEqual(got, want) ? params.alias : null;
85
+ }
62
86
  /** A pending-authorization artifact for the DCR consent interstitial (same
63
87
  * shape as `state`, distinct purpose so the two can't be confused). */
64
88
  export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
@@ -16,10 +16,10 @@ export interface AuthServerConfig {
16
16
  accessTtlSec?: number;
17
17
  masterKey: string;
18
18
  refreshStorePath: string;
19
- /** Default true. The clientless alias-only `flow=alias_reauth` link carries
19
+ /** Default true. The clientless `flow=alias_reauth` link names an alias with
20
20
  * no tenant binding, so a multi-tenant deployment sets false: with several
21
21
  * tenants an alias name alone is ambiguous and the branch becomes a
22
- * cross-tenant re-auth vector. Server-minted signed flows are unaffected. */
22
+ * cross-tenant re-auth vector. Server-minted alias_add flows are unaffected. */
23
23
  legacyAliasReauth?: boolean;
24
24
  }
25
25
  /** What /callback hands the alias_add binder once Google returned a verified
@@ -58,6 +58,12 @@ export interface AuthServerDeps {
58
58
  bundles?: string[];
59
59
  }) => string;
60
60
  writeToken?: (alias: string, tokens: Record<string, unknown>) => void;
61
+ /** Scopes the alias asked for that `grantedScope` lacks, so the alias_reauth
62
+ * completion page can say what Google's granular consent left out. */
63
+ missingScopes?: (alias: string, grantedScope: string | undefined) => string[];
64
+ /** Whether a readable token is stored for the alias; a narrower re-auth
65
+ * grant keeps it rather than replace it. */
66
+ hasToken?: (alias: string) => boolean;
61
67
  registeredClients?: Map<string, {
62
68
  redirect_uris: string[];
63
69
  }>;
@@ -88,12 +94,17 @@ export interface AuthServer {
88
94
  * INSIDE the signed state, so nothing at /authorize or /callback trusts a
89
95
  * caller-supplied identity. Inert unless something calls it. */
90
96
  mintFlowState: (opts: {
91
- flow: 'alias_add' | 'alias_reauth';
97
+ flow: 'alias_add';
92
98
  alias: string;
93
99
  tenantId?: string;
94
100
  bundles?: string[];
95
101
  nonce?: string;
96
102
  }) => Promise<string>;
103
+ /** The owner's re-auth link for `alias`: HMAC-signed, expiring, not single
104
+ * use (a chat client may prefetch it). /authorize refuses any alias_reauth
105
+ * request without a valid one, so an unauthenticated caller can neither
106
+ * start a re-auth nor learn which aliases exist or what they hold. */
107
+ reauthLink: (alias: string) => string;
97
108
  }
98
109
  /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
99
110
  * the fixed claude.ai callback. */
package/dist/oauth-as.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // whole surface is exercised by an offline harness.
8
8
  import { createHash, randomBytes } from 'node:crypto';
9
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';
10
+ import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
11
11
  import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
12
12
  const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
13
13
  // DCR (/register) is public + unauthenticated, so its in-memory store must be
@@ -240,6 +240,11 @@ export function buildAuthServer(config, deps = {}) {
240
240
  catch {
241
241
  return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
242
242
  }
243
+ // Only the client leg signs pendings, and it is always the owner sign-in;
244
+ // a pending signed before that rule (flow=alias_reauth) is refused here.
245
+ if (pending.flow !== 'owner_gate') {
246
+ return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
247
+ }
243
248
  if (!replay.consume(pending.jti, STATE_TTL_DEFAULT * 1000, now())) {
244
249
  return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
245
250
  }
@@ -280,17 +285,20 @@ export function buildAuthServer(config, deps = {}) {
280
285
  // In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
281
286
  // client leg — no code is delivered to any client, the refreshed token is
282
287
  // written server-side for the alias — so it skips the client_id/redirect/
283
- // PKCE requirements. Safe because /callback binds the completing Google
284
- // identity to the alias's configured email before writing anything.
285
- if (q.get('flow') === 'alias_reauth' && aliasParam && !clientId) {
288
+ // PKCE requirements. The link must be one this server signed (reauthLink),
289
+ // and /callback still binds the completing identity to the alias's email.
290
+ if (q.get('flow') === 'alias_reauth' && !clientId) {
286
291
  if (config.legacyAliasReauth === false) {
287
292
  // Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
288
293
  return errorPage(res, 403, 'E_LEGACY_REAUTH_DISABLED', 'alias re-auth links are disabled on this deployment; ask the server for a fresh account link');
289
294
  }
290
- if (!deps.aliasEmail?.(aliasParam)) {
291
- return errorPage(res, 400, 'invalid_request', `unknown account "${aliasParam}"`);
295
+ const alias = verifyReauthLink(base, secret, { alias: aliasParam ?? '', exp: q.get('exp') ?? '', sig: q.get('sig') ?? '' }, nowSec());
296
+ // One answer for an unsigned, forged, expired or stale link, so a
297
+ // request the server did not issue learns nothing about its aliases.
298
+ if (!alias || !deps.aliasEmail?.(alias)) {
299
+ return errorPage(res, 400, 'E_STATE_INVALID', 'this re-auth link is invalid or expired; ask the server for a fresh one');
292
300
  }
293
- return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias: aliasParam });
301
+ return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias });
294
302
  }
295
303
  if (!clientId)
296
304
  return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
@@ -310,14 +318,16 @@ export function buildAuthServer(config, deps = {}) {
310
318
  return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
311
319
  }
312
320
  // (5) only after full validation do we build the request artifact.
321
+ // A client leg is always the owner sign-in. Re-auth has its own signed
322
+ // link: accepting flow=alias_reauth here let any client (a self-registered
323
+ // one, or a public CIMD client_id) start it and read the alias's scopes.
313
324
  const statePayload = {
314
- flow: q.get('flow') === 'alias_reauth' ? 'alias_reauth' : 'owner_gate',
325
+ flow: 'owner_gate',
315
326
  client_id: clientId,
316
327
  redirect_uri: redirectUri,
317
328
  code_challenge: codeChallenge,
318
329
  client_state: clientState,
319
330
  resource,
320
- alias: q.get('alias') ?? undefined,
321
331
  };
322
332
  // Confused-deputy defense (#3): a self-registered DCR client can pick an
323
333
  // arbitrary redirect_uri, so before we send the owner to Google (whose
@@ -415,10 +425,26 @@ export function buildAuthServer(config, deps = {}) {
415
425
  log(`alias_reauth for "${st.alias}" returned no refresh token; stored token kept`);
416
426
  return errorPage(res, 400, 'E_REAUTH_INCOMPLETE', 'Google did not return a long-lived token, so the stored one was kept. Open the re-auth link again and approve access.');
417
427
  }
428
+ const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
429
+ const missing = deps.missingScopes?.(st.alias, granted) ?? [];
430
+ const listed = missing.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ');
431
+ // A narrower grant replaces nothing that works. The link can be reused
432
+ // for an hour and the Google URL's scope is not signed, so a narrower
433
+ // grant is not necessarily the account holder's choice.
434
+ if (missing.length > 0 && deps.hasToken?.(st.alias)) {
435
+ log(`alias_reauth for "${st.alias}" granted ${missing.length} fewer scope(s); stored token kept`);
436
+ res.writeHead(400, { 'Content-Type': 'text/html' });
437
+ res.end(`<!doctype html><meta charset=utf-8><p>E_SCOPE_NOT_GRANTED: Google did not grant ${missing.length} requested scope(s): ${listed}, so the stored access for "${escapeHtml(st.alias)}" was kept. Ask for a fresh re-auth link and leave every box ticked.</p>`);
438
+ return true;
439
+ }
418
440
  deps.writeToken?.(st.alias, exchanged.tokens);
419
441
  log(`callback ok flow=alias_reauth alias=${st.alias}`);
442
+ // With no stored token a partial grant still beats none; say what is missing.
443
+ const gap = missing.length
444
+ ? `<p>Google did not grant ${missing.length} requested scope(s): ${listed}. Ask for a fresh re-auth link and leave every box ticked to restore them.</p>`
445
+ : '';
420
446
  res.writeHead(200, { 'Content-Type': 'text/html' });
421
- res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>`);
447
+ res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>${gap}`);
422
448
  return true;
423
449
  }
424
450
  // owner_gate: resolve the verified email to its subject (default = the
@@ -537,8 +563,13 @@ export function buildAuthServer(config, deps = {}) {
537
563
  }
538
564
  };
539
565
  const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
566
+ // /authorize has a signed branch for alias_add only; anything else minted
567
+ // here would be a state no route verifies the way its flow needs.
568
+ if (flow !== 'alias_add')
569
+ throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
540
570
  const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, secret, nowSec());
541
571
  return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
542
572
  };
543
- return { routes, authenticate, mintFlowState };
573
+ const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
574
+ return { routes, authenticate, mintFlowState, reauthLink };
544
575
  }
@@ -1,2 +1,2 @@
1
- export declare function setHttpReauthBase(base: string | null): void;
1
+ export declare function setHttpReauthLink(linker: ((alias: string) => string) | null): void;
2
2
  export declare function reauthHint(account: string): string;
@@ -1,15 +1,15 @@
1
1
  // B13 in-call re-auth (BV-1): a dead/missing per-account Google token is a
2
2
  // leg-C failure. Over stdio the fix is the `auth --account` CLI; over HTTP the
3
- // user cannot run a CLI, so the hint becomes a clickable link into the AS's
4
- // clientless alias_reauth flow (${BASE}/authorize?flow=alias_reauth&alias=…).
5
- // The HTTP bootstrap sets the base when the AS starts; stdio never does.
6
- let httpReauthBase = null;
7
- export function setHttpReauthBase(base) {
8
- httpReauthBase = base;
3
+ // user cannot run a CLI, so the hint becomes a clickable, server-signed link
4
+ // into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
5
+ // bootstrap installs the linker when the AS starts; stdio never does.
6
+ let httpReauthLink = null;
7
+ export function setHttpReauthLink(linker) {
8
+ httpReauthLink = linker;
9
9
  }
10
10
  export function reauthHint(account) {
11
- if (httpReauthBase) {
12
- return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthBase}/authorize?flow=alias_reauth&alias=${encodeURIComponent(account)}`;
11
+ if (httpReauthLink) {
12
+ return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthLink(account)}`;
13
13
  }
14
14
  return `Run: npx mcp-google-multi auth --account ${account}`;
15
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.57",
3
+ "version": "6.0.0-alpha.58",
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",