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.
- package/dist/mcp-token.d.ts +6 -1
- package/dist/oauth-as.d.ts +31 -0
- package/dist/oauth-as.js +58 -4
- package/package.json +1 -1
package/dist/mcp-token.d.ts
CHANGED
|
@@ -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;
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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",
|