mcp-google-multi 6.0.0-alpha.39 → 6.0.0-alpha.40

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.
@@ -20,13 +20,18 @@ export interface AccessClaims {
20
20
  /** Verify an access token for `/mcp`. Throws on bad sig / aud / iss / exp. */
21
21
  export declare function verifyAccessToken(token: string, base: string, secret: Uint8Array): Promise<AccessClaims>;
22
22
  export interface StatePayload {
23
- flow: 'owner_gate' | 'alias_reauth';
23
+ flow: 'owner_gate' | 'alias_reauth' | 'alias_add';
24
24
  client_id: string;
25
25
  redirect_uri: string;
26
26
  code_challenge: string;
27
27
  client_state?: string;
28
28
  resource: string;
29
29
  alias?: string;
30
+ /** alias_add only: the tenant the new alias binds under. Signed server-side
31
+ * at mint time — never caller-supplied at /authorize or /callback. */
32
+ tenantId?: string;
33
+ /** alias_add only: scope bundles chosen when the link was minted. */
34
+ bundles?: string[];
30
35
  }
31
36
  export interface CodePayload {
32
37
  redirect_uri: string;
@@ -16,6 +16,11 @@ 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
20
+ * no tenant binding, so a multi-tenant deployment sets false: with several
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. */
23
+ legacyAliasReauth?: boolean;
19
24
  }
20
25
  export interface AuthServerDeps {
21
26
  fetchCimd?: (clientId: string) => Promise<Record<string, unknown>>;
@@ -41,10 +46,36 @@ export interface AuthServerDeps {
41
46
  log?: (line: string) => void;
42
47
  /** The alias's configured Google email, for the alias_reauth identity binding. */
43
48
  aliasEmail?: (alias: string) => string | undefined;
49
+ /** Map a verified sign-in email to the subject its tokens are minted under.
50
+ * Default = the MCP_OWNER_EMAILS allowlist -> {sub:'owner'}; null = denied.
51
+ * The tenant resolver (invite claim / email->tenant lookup) plugs in here. */
52
+ resolveSubject?: (email: string) => {
53
+ sub: string;
54
+ } | 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;
44
65
  }
45
66
  export interface AuthServer {
46
67
  routes: Record<string, RouteHandler>;
47
68
  authenticate: (req: IncomingMessage) => Promise<AuthOutcome>;
69
+ /** Mint a signed, single-use, TTL-bound clientless flow URL
70
+ * (`${base}/authorize?flow=...&state=<signed>`). The tenantId/bundles ride
71
+ * INSIDE the signed state, so nothing at /authorize or /callback trusts a
72
+ * caller-supplied identity. Inert unless something calls it (EE wizard). */
73
+ mintFlowState: (opts: {
74
+ flow: 'alias_add' | 'alias_reauth';
75
+ alias: string;
76
+ tenantId?: string;
77
+ bundles?: string[];
78
+ }) => Promise<string>;
48
79
  }
49
80
  /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
50
81
  * the fixed claude.ai callback. */
package/dist/oauth-as.js CHANGED
@@ -120,6 +120,7 @@ export function buildAuthServer(config, deps = {}) {
120
120
  const now = deps.now ?? (() => Date.now());
121
121
  const log = deps.log ?? (() => undefined);
122
122
  const nowSec = () => Math.floor(now() / 1000);
123
+ const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
123
124
  const issHeader = { 'Cache-Control': 'no-store' };
124
125
  const prm = {
125
126
  resource,
@@ -239,12 +240,38 @@ export function buildAuthServer(config, deps = {}) {
239
240
  const resourceParam = q.get('resource') ?? '';
240
241
  const clientState = q.get('state') ?? undefined;
241
242
  const aliasParam = q.get('alias') ?? undefined;
243
+ // Server-minted alias_add link: the whole request identity (tenant, alias,
244
+ // bundles) lives inside the SIGNED state — verify, spend it (single-use),
245
+ // then re-sign fresh for the Google leg. A tampered tenantId breaks the
246
+ // signature; a caller cannot supply one at all.
247
+ if (q.get('flow') === 'alias_add' && !clientId) {
248
+ let minted;
249
+ try {
250
+ minted = await verifyState(q.get('state') ?? '', base, secret);
251
+ }
252
+ catch {
253
+ return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is invalid, expired, or tampered');
254
+ }
255
+ if (minted.flow !== 'alias_add' || !minted.alias || !minted.tenantId) {
256
+ return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is malformed');
257
+ }
258
+ if (!replay.consume(minted.jti, STATE_TTL_DEFAULT * 1000, now())) {
259
+ return errorPage(res, 400, 'E_STATE_INVALID', 'this add-account link was already used');
260
+ }
261
+ const { jti: _spent, ...sp } = minted;
262
+ void _spent;
263
+ return toGoogle(res, sp);
264
+ }
242
265
  // In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
243
266
  // client leg — no code is delivered to any client, the refreshed token is
244
267
  // written server-side for the alias — so it skips the client_id/redirect/
245
268
  // PKCE requirements. Safe because /callback binds the completing Google
246
269
  // identity to the alias's configured email before writing anything.
247
270
  if (q.get('flow') === 'alias_reauth' && aliasParam && !clientId) {
271
+ if (config.legacyAliasReauth === false) {
272
+ // Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
273
+ 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');
274
+ }
248
275
  if (!deps.aliasEmail?.(aliasParam)) {
249
276
  return errorPage(res, 400, 'invalid_request', `unknown account "${aliasParam}"`);
250
277
  }
@@ -321,6 +348,26 @@ export function buildAuthServer(config, deps = {}) {
321
348
  catch (e) {
322
349
  return errorPage(res, 400, 'invalid_grant', `Google code exchange failed: ${e.message}`);
323
350
  }
351
+ if (st.flow === 'alias_add') {
352
+ if (!st.alias || !st.tenantId)
353
+ return errorPage(res, 400, 'invalid_request', 'alias_add without an alias/tenant binding');
354
+ if (!deps.bindTenantAlias)
355
+ return errorPage(res, 500, 'E_ALIAS_ADD_UNAVAILABLE', 'account linking is not enabled on this server');
356
+ const boundEmail = (exchanged.email ?? '').toLowerCase();
357
+ if (!boundEmail)
358
+ return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
359
+ try {
360
+ deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, tokens: exchanged.tokens });
361
+ }
362
+ catch (e) {
363
+ log(`alias_add bind failed for "${st.alias}": ${e.message}`);
364
+ 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
+ }
366
+ log(`callback ok flow=alias_add alias=${st.alias}`);
367
+ 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>`);
369
+ return true;
370
+ }
324
371
  if (st.flow === 'alias_reauth') {
325
372
  if (!st.alias)
326
373
  return errorPage(res, 400, 'invalid_request', 'alias_reauth without an alias');
@@ -341,15 +388,18 @@ export function buildAuthServer(config, deps = {}) {
341
388
  res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${st.alias}". You can close this window.</p>`);
342
389
  return true;
343
390
  }
344
- // owner_gate: verify the email is an allowlisted owner, discard Google tokens
391
+ // owner_gate: resolve the verified email to its subject (default = the
392
+ // owner allowlist -> 'owner'), discard Google tokens. The resolved sub is
393
+ // signed into the authz code and flows into every token minted from it.
345
394
  const email = (exchanged.email ?? '').toLowerCase();
346
395
  const iss = encodeURIComponent(base);
347
- if (!email || !config.ownerEmails.includes(email)) {
396
+ const subject = email ? resolveSubject(email) : null;
397
+ if (!subject) {
348
398
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
349
399
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
350
400
  return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
351
401
  }
352
- const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: 'owner' }, base, secret, nowSec());
402
+ const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, secret, nowSec());
353
403
  log('callback ok flow=owner_gate');
354
404
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
355
405
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
@@ -453,5 +503,9 @@ export function buildAuthServer(config, deps = {}) {
453
503
  return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
454
504
  }
455
505
  };
456
- return { routes, authenticate };
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());
508
+ return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
509
+ };
510
+ return { routes, authenticate, mintFlowState };
457
511
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.39",
3
+ "version": "6.0.0-alpha.40",
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",