mcp-google-multi 6.0.0-alpha.62 → 6.0.0-alpha.63

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.
@@ -35,6 +35,9 @@ export interface StatePayload {
35
35
  /** alias_add only: an opaque value the minting caller chose, signed like
36
36
  * tenantId and handed back to its binder (e.g. a server-side link record). */
37
37
  nonce?: string;
38
+ /** owner_gate only: base64url sha256 of the browser-binding cookie the
39
+ * Google redirect set; /callback refuses a browser that lacks it. */
40
+ bind?: string;
38
41
  }
39
42
  export interface CodePayload {
40
43
  redirect_uri: string;
package/dist/mcp-token.js CHANGED
@@ -20,6 +20,8 @@ function newJti() {
20
20
  return randomBytes(16).toString('base64url');
21
21
  }
22
22
  export async function signAccessToken(p) {
23
+ if (typeof p.sub !== 'string' || p.sub === '')
24
+ throw new Error('signAccessToken: sub must be a non-empty string');
23
25
  const ttl = p.ttlSec ?? ACCESS_TTL_DEFAULT;
24
26
  return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
25
27
  .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
@@ -36,7 +38,9 @@ export async function verifyAccessToken(token, base, secret) {
36
38
  const { payload } = await jwtVerify(token, secret, { issuer: base, audience: `${base}/mcp` });
37
39
  if (payload.purpose !== 'mcp_access')
38
40
  throw new Error('wrong token purpose');
39
- return { sub: String(payload.sub), scope: String(payload.scope ?? ''), jti: String(payload.jti ?? '') };
41
+ if (typeof payload.sub !== 'string' || payload.sub === '')
42
+ throw new Error('access token has no subject');
43
+ return { sub: payload.sub, scope: String(payload.scope ?? ''), jti: String(payload.jti ?? '') };
40
44
  }
41
45
  async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
42
46
  return new SignJWT({ ...claims, purpose })
@@ -129,6 +133,16 @@ export class ReplayGuard {
129
133
  }
130
134
  }
131
135
  const SPENT_CAP = 2000;
136
+ function ownEntries(o) {
137
+ // An own `__proto__` key (JSON.parse makes one) would set the prototype when copied.
138
+ return o !== null && typeof o === 'object' && !Array.isArray(o) ? Object.entries(o).filter(([k]) => k !== '__proto__') : [];
139
+ }
140
+ function isLegacyRecord(r) {
141
+ if (r === null || typeof r !== 'object')
142
+ return false;
143
+ const x = r;
144
+ return typeof x.sub === 'string' && x.sub !== '' && typeof x.family === 'string' && typeof x.issuedAt === 'number' && Number.isFinite(x.issuedAt);
145
+ }
132
146
  /**
133
147
  * Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
134
148
  * every use (a stolen token is usable at most once), and detects REUSE of a
@@ -144,15 +158,26 @@ export class RefreshStore {
144
158
  this.masterKey = masterKey;
145
159
  }
146
160
  load() {
161
+ const data = { active: {}, spent: {} };
147
162
  if (!existsSync(this.path))
148
- return { active: {}, spent: {} };
163
+ return data;
164
+ let d;
149
165
  try {
150
- const d = decryptToken(readFileSync(this.path, 'utf-8'), this.masterKey);
151
- return { active: d.active ?? {}, spent: d.spent ?? {} };
166
+ d = decryptToken(readFileSync(this.path, 'utf-8'), this.masterKey);
152
167
  }
153
168
  catch {
154
- return { active: {}, spent: {} };
169
+ return data;
170
+ }
171
+ // Only well-formed own entries survive, so no lookup can land on an
172
+ // inherited key or a record without a subject.
173
+ for (const [t, r] of ownEntries(d?.active)) {
174
+ if (isLegacyRecord(r))
175
+ data.active[t] = { sub: r.sub, issuedAt: r.issuedAt, family: r.family };
155
176
  }
177
+ for (const [t, fam] of ownEntries(d?.spent))
178
+ if (typeof fam === 'string')
179
+ data.spent[t] = fam;
180
+ return data;
156
181
  }
157
182
  save(data) {
158
183
  // A family with no active token has nothing left to revoke. Over the cap,
@@ -215,10 +240,10 @@ export class RefreshStore {
215
240
  rotate(oldToken, nowMs, accept) {
216
241
  return withFileLock(this.path, () => {
217
242
  const data = this.load();
218
- const rec = data.active[oldToken];
243
+ const rec = Object.hasOwn(data.active, oldToken) ? data.active[oldToken] : undefined;
219
244
  if (!rec) {
220
245
  // Reuse of a rotated-away token signals theft: revoke the whole family.
221
- const fam = data.spent[oldToken];
246
+ const fam = Object.hasOwn(data.spent, oldToken) ? data.spent[oldToken] : undefined;
222
247
  if (fam) {
223
248
  for (const [t, r] of Object.entries(data.active))
224
249
  if (r.family === fam)
package/dist/oauth-as.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
6
6
  // an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
7
7
  // whole surface is exercised by an offline harness.
8
- import { createHash, randomBytes } from 'node:crypto';
8
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { isIPv4 } from 'node:net';
10
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';
@@ -61,8 +61,8 @@ class SubjectCheckUnavailable extends Error {
61
61
  function subjectUnavailable(res) {
62
62
  return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
63
63
  }
64
- function redirect(res, location) {
65
- res.writeHead(302, { Location: location });
64
+ function redirect(res, location, extraHeaders = {}) {
65
+ res.writeHead(302, { Location: location, ...extraHeaders });
66
66
  res.end();
67
67
  return true;
68
68
  }
@@ -102,6 +102,38 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
102
102
  return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
103
103
  });
104
104
  }
105
+ // The owner_gate browser binding (docs/internals.md). One cookie per flow, so
106
+ // two sign-ins started in one browser do not overwrite each other. __Host-
107
+ // requires Secure, which an http base (the loopback owner) cannot rely on in
108
+ // every browser, so an http base gets a plain host-only name instead.
109
+ const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
110
+ function bindCookieName(base, bind) {
111
+ return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
112
+ }
113
+ function bindCookieAttrs(base, maxAge) {
114
+ return `Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${base.startsWith('https:') ? '; Secure' : ''}`;
115
+ }
116
+ function readCookie(req, name) {
117
+ for (const part of (req.headers.cookie ?? '').split(';')) {
118
+ const i = part.indexOf('=');
119
+ if (i > 0 && part.slice(0, i).trim() === name)
120
+ return part.slice(i + 1).trim();
121
+ }
122
+ return undefined;
123
+ }
124
+ function sha256Url(value) {
125
+ return createHash('sha256').update(value).digest('base64url');
126
+ }
127
+ function browserBinding(req, base, bind) {
128
+ if (typeof bind !== 'string' || !BIND_HASH.test(bind))
129
+ return 'missing';
130
+ const value = readCookie(req, bindCookieName(base, bind));
131
+ if (!value)
132
+ return 'missing';
133
+ const got = createHash('sha256').update(value).digest();
134
+ const want = Buffer.from(bind, 'base64url');
135
+ return got.length === want.length && timingSafeEqual(got, want) ? 'ok' : 'mismatch';
136
+ }
105
137
  function pkceS256(verifier) {
106
138
  return createHash('sha256').update(verifier).digest('base64url');
107
139
  }
@@ -234,11 +266,20 @@ export function buildAuthServer(config, deps = {}) {
234
266
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
235
267
  }
236
268
  async function toGoogle(res, sp) {
269
+ // H4 F1: without this, a Google URL minted here and forwarded to someone
270
+ // else signs THEM in and hands their subject to the starting client.
271
+ const headers = {};
272
+ if (sp.flow === 'owner_gate') {
273
+ const value = randomBytes(32).toString('base64url');
274
+ const bind = sha256Url(value);
275
+ sp = { ...sp, bind };
276
+ headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
277
+ }
237
278
  const state = await signState(sp, base, secret, nowSec());
238
279
  const authUrl = deps.buildGoogleAuthUrl
239
280
  ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
240
281
  : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
241
- return redirect(res, authUrl);
282
+ return redirect(res, authUrl, headers);
242
283
  }
243
284
  // GET /authorize (validate + interstitial for DCR clients); POST /authorize
244
285
  // (a DCR consent approval — verify the signed pending token, then proceed).
@@ -355,7 +396,7 @@ export function buildAuthServer(config, deps = {}) {
355
396
  // cannot forge, so there is nothing to spoof.
356
397
  if (client.dcr) {
357
398
  const pendingToken = await signPending(statePayload, base, secret, nowSec());
358
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
399
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
359
400
  res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
360
401
  `<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
361
402
  `<h2>Authorize access?</h2>` +
@@ -382,6 +423,16 @@ export function buildAuthServer(config, deps = {}) {
382
423
  if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
383
424
  return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
384
425
  }
426
+ // After the spend, so a refused callback URL cannot be replayed from the
427
+ // browser that holds the cookie; before the exchange and resolveSubject.
428
+ if (st.flow === 'owner_gate') {
429
+ const binding = browserBinding(req, base, st.bind);
430
+ if (binding !== 'ok') {
431
+ log(`callback refused flow=owner_gate: browser binding ${binding}`);
432
+ return errorPage(res, 400, 'E_BROWSER_MISMATCH', 'finish the sign-in in the browser that started it; start again from your app');
433
+ }
434
+ res.setHeader('Set-Cookie', `${bindCookieName(base, st.bind ?? '')}=; ${bindCookieAttrs(base, 0)}`);
435
+ }
385
436
  if (!code)
386
437
  return errorPage(res, 400, 'invalid_request', 'missing code');
387
438
  let exchanged;
@@ -389,7 +440,10 @@ export function buildAuthServer(config, deps = {}) {
389
440
  exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
390
441
  }
391
442
  catch (e) {
392
- return errorPage(res, 400, 'invalid_grant', `Google code exchange failed: ${e.message}`);
443
+ // The detail can name proxies and hosts; it goes to the log, not the browser.
444
+ const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
445
+ log(`callback exchange failed flow=${st.flow}: ${detail.replace(/[\r\n]+/g, ' ')}`);
446
+ return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
393
447
  }
394
448
  if (st.flow === 'alias_add') {
395
449
  if (!st.alias || !st.tenantId)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.62",
3
+ "version": "6.0.0-alpha.63",
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",