mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2
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/README.md +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +86 -0
- package/dist/doctor.js +384 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +59 -0
- package/dist/http-transport.js +269 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +199 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +27 -1
- package/dist/registry.js +117 -6
- package/dist/scope-catalog.d.ts +19 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +97 -6
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +3 -0
- package/dist/tools/drive.js +60 -8
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +483 -99
- package/dist/tools/google-api.js +7 -4
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +26 -7
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { configDir, failStartup } from './config-file.js';
|
|
6
|
+
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
7
|
+
import { getTokenDir } from './accounts.js';
|
|
8
|
+
// The keychain is a native optionalDependency: load lazily and synchronously
|
|
9
|
+
// (createRequire — the token store's key read is sync), and treat EVERY
|
|
10
|
+
// failure (no prebuild, native load error, headless Linux without a Secret
|
|
11
|
+
// Service) as "keychain unavailable", falling through to the 0600 file.
|
|
12
|
+
const require_ = createRequire(import.meta.url);
|
|
13
|
+
function systemKeychain(account) {
|
|
14
|
+
try {
|
|
15
|
+
const { Entry } = require_('@napi-rs/keyring');
|
|
16
|
+
const entry = new Entry('mcp-google-multi', account);
|
|
17
|
+
return {
|
|
18
|
+
get: () => {
|
|
19
|
+
try {
|
|
20
|
+
return entry.getPassword();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
set: (value) => entry.setPassword(value),
|
|
27
|
+
del: () => entry.deletePassword(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Default keychain factory, replaceable ONLY for tests: unit tests must never
|
|
35
|
+
// read or write the developer's real OS keyring (the BR-5 mirror once leaked a
|
|
36
|
+
// test fixture key into a real GNOME keyring — never again).
|
|
37
|
+
let keychainFactory = systemKeychain;
|
|
38
|
+
export function __setKeychainFactoryForTest(fn) {
|
|
39
|
+
keychainFactory = fn ?? systemKeychain;
|
|
40
|
+
}
|
|
41
|
+
// Glob the token dir instead of iterating registered aliases: an orphan .enc
|
|
42
|
+
// (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
|
|
43
|
+
// one client) must still stop a fresh key from being minted.
|
|
44
|
+
function anyTokenFileExists() {
|
|
45
|
+
try {
|
|
46
|
+
return fs.readdirSync(getTokenDir()).some((f) => f.endsWith('.enc'));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function readKeyFile(filePath) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
|
55
|
+
if (!raw)
|
|
56
|
+
return null;
|
|
57
|
+
try {
|
|
58
|
+
const mode = fs.statSync(filePath).mode & 0o777;
|
|
59
|
+
if (process.platform !== 'win32' && (mode & 0o077) !== 0) {
|
|
60
|
+
process.stderr.write(`WARN: ${filePath} is readable by others (mode ${mode.toString(8)}); chmod 600 it.\n`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// stat raced a delete; the read already succeeded.
|
|
65
|
+
}
|
|
66
|
+
return raw;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
73
|
+
const env = deps.env ?? process.env;
|
|
74
|
+
const dir = deps.dir ?? configDir();
|
|
75
|
+
const keychain = deps.keychain ?? keychainFactory;
|
|
76
|
+
const hasAnyToken = deps.hasAnyToken ?? anyTokenFileExists;
|
|
77
|
+
const fail = (slug, message) => {
|
|
78
|
+
if (onFail === 'throw')
|
|
79
|
+
throw new Error(`${slug}: ${message}`);
|
|
80
|
+
failStartup(slug, message);
|
|
81
|
+
};
|
|
82
|
+
const fromEnv = env[envName]?.trim();
|
|
83
|
+
if (fromEnv)
|
|
84
|
+
return { key: fromEnv, provenance: 'env' };
|
|
85
|
+
const entry = keychain(envName);
|
|
86
|
+
const fromKeychain = entry?.get();
|
|
87
|
+
if (fromKeychain)
|
|
88
|
+
return { key: fromKeychain, provenance: 'keychain' };
|
|
89
|
+
const filePath = path.join(dir, fileName);
|
|
90
|
+
const fromFile = readKeyFile(filePath);
|
|
91
|
+
if (fromFile)
|
|
92
|
+
return { key: fromFile, provenance: 'file' };
|
|
93
|
+
// Generate-on-setup. The hard guard is cc-security Cgen: minting a fresh
|
|
94
|
+
// MASTER_KEY while <alias>.enc files exist would silently brick them all.
|
|
95
|
+
if (hardGuard && hasAnyToken()) {
|
|
96
|
+
fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
|
|
97
|
+
'Restore the original key, or delete those .enc files and re-auth each account.');
|
|
98
|
+
}
|
|
99
|
+
// Lock the generate critical section so two concurrent first-boots cannot
|
|
100
|
+
// both mint (the loser re-reads the winner's key under the lock).
|
|
101
|
+
return withFileLock(filePath, () => {
|
|
102
|
+
const racedKeychain = keychain(envName)?.get();
|
|
103
|
+
if (racedKeychain)
|
|
104
|
+
return { key: racedKeychain, provenance: 'keychain' };
|
|
105
|
+
const racedFile = readKeyFile(filePath);
|
|
106
|
+
if (racedFile)
|
|
107
|
+
return { key: racedFile, provenance: 'file' };
|
|
108
|
+
// Exactly randomBytes(32) base64: hits deriveKey's 32-byte fast path.
|
|
109
|
+
const generated = randomBytes(32).toString('base64');
|
|
110
|
+
// The 0600 file is ALWAYS written: on Linux the "keychain" can be the
|
|
111
|
+
// kernel keyutils keyring, which does not survive a reboot — a key that
|
|
112
|
+
// lives only there bricks every token at the next boot. The file is the
|
|
113
|
+
// durable sink; byte-exact read-back gates any use of the key.
|
|
114
|
+
atomicWriteFileSync(filePath, `${generated}\n`, 0o600);
|
|
115
|
+
if (readKeyFile(filePath) !== generated) {
|
|
116
|
+
fail('E_KEY_PROVISIONING_FAILED', `generated ${envName} could not be read back byte-exact from ${filePath}; refusing to encrypt anything with an unverified key.`);
|
|
117
|
+
}
|
|
118
|
+
// Keychain copy is best-effort QoL; a mismatched read-back would SHADOW
|
|
119
|
+
// the file on the next boot (keychain > file), so clean it up.
|
|
120
|
+
let keychainNote = '';
|
|
121
|
+
try {
|
|
122
|
+
if (entry) {
|
|
123
|
+
entry.set(generated);
|
|
124
|
+
if (keychain(envName)?.get() === generated) {
|
|
125
|
+
keychainNote = ' and the OS keychain (service mcp-google-multi)';
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
try {
|
|
129
|
+
entry.del();
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
process.stderr.write(`WARN: OS keychain holds a mismatched ${envName} entry; the ${fileName} file is authoritative.\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Keychain unavailable; the file already has the key.
|
|
139
|
+
}
|
|
140
|
+
process.stderr.write(`Generated ${envName}: stored at ${filePath} (0600)${keychainNote}.\n`);
|
|
141
|
+
return { key: generated, provenance: 'generated' };
|
|
142
|
+
}, `${envName} provisioning`);
|
|
143
|
+
}
|
|
144
|
+
let cachedMaster = null;
|
|
145
|
+
let cachedJwt = null;
|
|
146
|
+
/** BR-1 single choke point: no other module may read the env vars directly.
|
|
147
|
+
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
148
|
+
export function resolveMasterKey(deps) {
|
|
149
|
+
if (deps)
|
|
150
|
+
return resolveKey('MASTER_KEY', 'master.key', true, deps);
|
|
151
|
+
cachedMaster ??= resolveKey('MASTER_KEY', 'master.key', true, {});
|
|
152
|
+
return cachedMaster;
|
|
153
|
+
}
|
|
154
|
+
/** Dispatch form: identical resolution, but failures THROW (caught by the tool
|
|
155
|
+
* handler into an error envelope) — a live server is never process.exit'd. */
|
|
156
|
+
export function resolveMasterKeyForDispatch() {
|
|
157
|
+
cachedMaster ??= resolveKey('MASTER_KEY', 'master.key', true, {}, 'throw');
|
|
158
|
+
return cachedMaster;
|
|
159
|
+
}
|
|
160
|
+
/** Side-effect-free provenance probe for diagnostics: NEVER generates, never
|
|
161
|
+
* fires the hard guard (config check must not mint keys or exit). */
|
|
162
|
+
export function peekMasterKeyProvenance(deps) {
|
|
163
|
+
const env = deps?.env ?? process.env;
|
|
164
|
+
if (env.MASTER_KEY?.trim())
|
|
165
|
+
return 'env';
|
|
166
|
+
const keychain = deps?.keychain ?? keychainFactory;
|
|
167
|
+
if (keychain('MASTER_KEY')?.get())
|
|
168
|
+
return 'keychain';
|
|
169
|
+
const dir = deps?.dir ?? configDir();
|
|
170
|
+
if (readKeyFile(path.join(dir, 'master.key')))
|
|
171
|
+
return 'file';
|
|
172
|
+
return 'unprovisioned';
|
|
173
|
+
}
|
|
174
|
+
/** MCP_JWT_KEY variant: NO hard guard — regenerating only invalidates
|
|
175
|
+
* outstanding MCP tokens and clients re-auth silently. */
|
|
176
|
+
export function resolveJwtKey(deps) {
|
|
177
|
+
if (deps)
|
|
178
|
+
return resolveKey('MCP_JWT_KEY', 'mcp-jwt.key', false, deps);
|
|
179
|
+
cachedJwt ??= resolveKey('MCP_JWT_KEY', 'mcp-jwt.key', false, {});
|
|
180
|
+
return cachedJwt;
|
|
181
|
+
}
|
|
182
|
+
let mirrored = false;
|
|
183
|
+
/**
|
|
184
|
+
* BR-5: after the FIRST successful token decrypt with an env-sourced key,
|
|
185
|
+
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
186
|
+
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
187
|
+
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
188
|
+
* never fatal.
|
|
189
|
+
*/
|
|
190
|
+
export function noteSuccessfulDecrypt() {
|
|
191
|
+
if (mirrored)
|
|
192
|
+
return;
|
|
193
|
+
mirrored = true;
|
|
194
|
+
try {
|
|
195
|
+
if (cachedMaster?.provenance !== 'env')
|
|
196
|
+
return;
|
|
197
|
+
const entry = keychainFactory('MASTER_KEY');
|
|
198
|
+
if (!entry || entry.get())
|
|
199
|
+
return;
|
|
200
|
+
entry.set(cachedMaster.key);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Keychain backend unavailable; env stays the working source.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
207
|
+
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
208
|
+
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
209
|
+
* have wiped every .enc token first — regenerating with tokens present would
|
|
210
|
+
* brick them (enforced by the reset planner). */
|
|
211
|
+
export function deleteMasterKeyMaterial(deps) {
|
|
212
|
+
const dir = deps?.dir ?? configDir();
|
|
213
|
+
const keychain = deps?.keychain ?? keychainFactory;
|
|
214
|
+
const env = Boolean(process.env.MASTER_KEY?.trim());
|
|
215
|
+
let file = false;
|
|
216
|
+
let kc = false;
|
|
217
|
+
try {
|
|
218
|
+
fs.unlinkSync(path.join(dir, 'master.key'));
|
|
219
|
+
file = true;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// already absent
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const entry = keychain('MASTER_KEY');
|
|
226
|
+
if (entry && entry.get()) {
|
|
227
|
+
entry.del();
|
|
228
|
+
kc = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// keychain unavailable
|
|
233
|
+
}
|
|
234
|
+
cachedMaster = null;
|
|
235
|
+
return { file, keychain: kc, env };
|
|
236
|
+
}
|
|
237
|
+
/** Test hook: the production cache is process-lifetime by design. */
|
|
238
|
+
export function clearKeyCacheForTest() {
|
|
239
|
+
cachedMaster = null;
|
|
240
|
+
cachedJwt = null;
|
|
241
|
+
mirrored = false;
|
|
242
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export declare const ACCESS_TTL_DEFAULT = 600;
|
|
2
|
+
export declare const STATE_TTL_DEFAULT = 600;
|
|
3
|
+
export declare const CODE_TTL_DEFAULT = 60;
|
|
4
|
+
export declare const REPLAY_CAP_DEFAULT = 10000;
|
|
5
|
+
/** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
|
|
6
|
+
export declare function jwtSecretFrom(jwtKey: string): Uint8Array;
|
|
7
|
+
export interface AccessTokenParams {
|
|
8
|
+
base: string;
|
|
9
|
+
secret: Uint8Array;
|
|
10
|
+
ttlSec?: number;
|
|
11
|
+
iat: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function signAccessToken(p: AccessTokenParams): Promise<string>;
|
|
14
|
+
export interface AccessClaims {
|
|
15
|
+
sub: string;
|
|
16
|
+
scope: string;
|
|
17
|
+
jti: string;
|
|
18
|
+
}
|
|
19
|
+
/** Verify an access token for `/mcp`. Throws on bad sig / aud / iss / exp. */
|
|
20
|
+
export declare function verifyAccessToken(token: string, base: string, secret: Uint8Array): Promise<AccessClaims>;
|
|
21
|
+
export interface StatePayload {
|
|
22
|
+
flow: 'owner_gate' | 'alias_reauth';
|
|
23
|
+
client_id: string;
|
|
24
|
+
redirect_uri: string;
|
|
25
|
+
code_challenge: string;
|
|
26
|
+
client_state?: string;
|
|
27
|
+
resource: string;
|
|
28
|
+
alias?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface CodePayload {
|
|
31
|
+
redirect_uri: string;
|
|
32
|
+
code_challenge: string;
|
|
33
|
+
resource: string;
|
|
34
|
+
sub: 'owner';
|
|
35
|
+
}
|
|
36
|
+
export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
37
|
+
export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
38
|
+
jti: string;
|
|
39
|
+
}>;
|
|
40
|
+
/** A pending-authorization artifact for the DCR consent interstitial (same
|
|
41
|
+
* shape as `state`, distinct purpose so the two can't be confused). */
|
|
42
|
+
export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
43
|
+
export declare function verifyPending(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
44
|
+
jti: string;
|
|
45
|
+
}>;
|
|
46
|
+
export declare function signAuthzCode(payload: CodePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
47
|
+
export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array): Promise<CodePayload & {
|
|
48
|
+
jti: string;
|
|
49
|
+
}>;
|
|
50
|
+
export declare class ReplayGuard {
|
|
51
|
+
private readonly cap;
|
|
52
|
+
private readonly seen;
|
|
53
|
+
constructor(cap?: number);
|
|
54
|
+
/** Record a jti as spent. Returns false if it was already spent (replay). */
|
|
55
|
+
consume(jti: string, ttlMs: number, nowMs: number): boolean;
|
|
56
|
+
get size(): number;
|
|
57
|
+
private evictExpired;
|
|
58
|
+
}
|
|
59
|
+
export interface RefreshRecord {
|
|
60
|
+
sub: 'owner';
|
|
61
|
+
issuedAt: number;
|
|
62
|
+
family: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
|
|
66
|
+
* every use (a stolen token is usable at most once), and detects REUSE of a
|
|
67
|
+
* rotated-away token as theft: the whole token family is revoked (#7 / C14).
|
|
68
|
+
* All read-modify-write goes through a file lock so concurrent stdio+HTTP
|
|
69
|
+
* processes can't lost-update or double-spend (#12).
|
|
70
|
+
*/
|
|
71
|
+
export declare class RefreshStore {
|
|
72
|
+
private readonly path;
|
|
73
|
+
private readonly masterKey;
|
|
74
|
+
constructor(path: string, masterKey: string);
|
|
75
|
+
private load;
|
|
76
|
+
private save;
|
|
77
|
+
issue(nowMs: number, family?: string): string;
|
|
78
|
+
/** Rotate a presented refresh token; null if unknown OR if the presented
|
|
79
|
+
* token was already rotated away (reuse => the family is revoked). */
|
|
80
|
+
rotate(oldToken: string, nowMs: number): string | null;
|
|
81
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// B13 token model (oauth-authorization-server.md §Data model): the MCP access
|
|
2
|
+
// token (HS256 JWT, jose-only — AS==RS so no JWKS), the signed self-contained
|
|
3
|
+
// `state` and authorization code, the in-memory single-use replay guard, and
|
|
4
|
+
// the opaque rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
|
|
5
|
+
// (B5), so tokens survive a restart as long as the key persists.
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
9
|
+
import { deriveKey, encryptToken, decryptToken } from './token-store.js';
|
|
10
|
+
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
11
|
+
export const ACCESS_TTL_DEFAULT = 600; // seconds
|
|
12
|
+
export const STATE_TTL_DEFAULT = 600;
|
|
13
|
+
export const CODE_TTL_DEFAULT = 60;
|
|
14
|
+
export const REPLAY_CAP_DEFAULT = 10_000;
|
|
15
|
+
/** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
|
|
16
|
+
export function jwtSecretFrom(jwtKey) {
|
|
17
|
+
return new Uint8Array(deriveKey(jwtKey));
|
|
18
|
+
}
|
|
19
|
+
function newJti() {
|
|
20
|
+
return randomBytes(16).toString('base64url');
|
|
21
|
+
}
|
|
22
|
+
export async function signAccessToken(p) {
|
|
23
|
+
const ttl = p.ttlSec ?? ACCESS_TTL_DEFAULT;
|
|
24
|
+
return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
|
|
25
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
26
|
+
.setIssuer(p.base)
|
|
27
|
+
.setSubject('owner')
|
|
28
|
+
.setAudience(`${p.base}/mcp`)
|
|
29
|
+
.setIssuedAt(p.iat)
|
|
30
|
+
.setExpirationTime(p.iat + ttl)
|
|
31
|
+
.setJti(newJti())
|
|
32
|
+
.sign(p.secret);
|
|
33
|
+
}
|
|
34
|
+
/** Verify an access token for `/mcp`. Throws on bad sig / aud / iss / exp. */
|
|
35
|
+
export async function verifyAccessToken(token, base, secret) {
|
|
36
|
+
const { payload } = await jwtVerify(token, secret, { issuer: base, audience: `${base}/mcp` });
|
|
37
|
+
if (payload.purpose !== 'mcp_access')
|
|
38
|
+
throw new Error('wrong token purpose');
|
|
39
|
+
return { sub: String(payload.sub), scope: String(payload.scope ?? ''), jti: String(payload.jti ?? '') };
|
|
40
|
+
}
|
|
41
|
+
async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
|
|
42
|
+
return new SignJWT({ ...claims, purpose })
|
|
43
|
+
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
44
|
+
.setIssuer(base)
|
|
45
|
+
.setIssuedAt(iat)
|
|
46
|
+
.setExpirationTime(iat + ttlSec)
|
|
47
|
+
.setJti(newJti())
|
|
48
|
+
.sign(secret);
|
|
49
|
+
}
|
|
50
|
+
async function verifyArtifact(token, purpose, base, secret) {
|
|
51
|
+
const { payload } = await jwtVerify(token, secret, { issuer: base });
|
|
52
|
+
if (payload.purpose !== purpose)
|
|
53
|
+
throw new Error(`wrong artifact purpose (${String(payload.purpose)})`);
|
|
54
|
+
return { ...payload, jti: String(payload.jti ?? '') };
|
|
55
|
+
}
|
|
56
|
+
export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
|
|
57
|
+
return signArtifact(payload, 'mcp_state', base, secret, iat, ttlSec);
|
|
58
|
+
}
|
|
59
|
+
export async function verifyState(token, base, secret) {
|
|
60
|
+
return (await verifyArtifact(token, 'mcp_state', base, secret));
|
|
61
|
+
}
|
|
62
|
+
/** A pending-authorization artifact for the DCR consent interstitial (same
|
|
63
|
+
* shape as `state`, distinct purpose so the two can't be confused). */
|
|
64
|
+
export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
|
|
65
|
+
return signArtifact(payload, 'mcp_pending', base, secret, iat, ttlSec);
|
|
66
|
+
}
|
|
67
|
+
export async function verifyPending(token, base, secret) {
|
|
68
|
+
return (await verifyArtifact(token, 'mcp_pending', base, secret));
|
|
69
|
+
}
|
|
70
|
+
export function signAuthzCode(payload, base, secret, iat, ttlSec = CODE_TTL_DEFAULT) {
|
|
71
|
+
return signArtifact(payload, 'mcp_code', base, secret, iat, ttlSec);
|
|
72
|
+
}
|
|
73
|
+
export async function verifyAuthzCode(token, base, secret) {
|
|
74
|
+
return (await verifyArtifact(token, 'mcp_code', base, secret));
|
|
75
|
+
}
|
|
76
|
+
// --- Replay guard (C10/C17: single-use, capped, TTL-evicted) ----------------
|
|
77
|
+
export class ReplayGuard {
|
|
78
|
+
cap;
|
|
79
|
+
seen = new Map(); // jti -> expiry (ms)
|
|
80
|
+
constructor(cap = REPLAY_CAP_DEFAULT) {
|
|
81
|
+
this.cap = cap;
|
|
82
|
+
}
|
|
83
|
+
/** Record a jti as spent. Returns false if it was already spent (replay). */
|
|
84
|
+
consume(jti, ttlMs, nowMs) {
|
|
85
|
+
this.evictExpired(nowMs);
|
|
86
|
+
if (this.seen.has(jti))
|
|
87
|
+
return false;
|
|
88
|
+
if (this.seen.size >= this.cap) {
|
|
89
|
+
// drop the oldest-inserted entry to bound memory (C17)
|
|
90
|
+
const oldest = this.seen.keys().next().value;
|
|
91
|
+
if (oldest !== undefined)
|
|
92
|
+
this.seen.delete(oldest);
|
|
93
|
+
}
|
|
94
|
+
this.seen.set(jti, nowMs + ttlMs);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
get size() {
|
|
98
|
+
return this.seen.size;
|
|
99
|
+
}
|
|
100
|
+
evictExpired(nowMs) {
|
|
101
|
+
for (const [jti, exp] of this.seen) {
|
|
102
|
+
if (exp <= nowMs)
|
|
103
|
+
this.seen.delete(jti);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const SPENT_CAP = 2000;
|
|
108
|
+
/**
|
|
109
|
+
* Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
|
|
110
|
+
* every use (a stolen token is usable at most once), and detects REUSE of a
|
|
111
|
+
* rotated-away token as theft: the whole token family is revoked (#7 / C14).
|
|
112
|
+
* All read-modify-write goes through a file lock so concurrent stdio+HTTP
|
|
113
|
+
* processes can't lost-update or double-spend (#12).
|
|
114
|
+
*/
|
|
115
|
+
export class RefreshStore {
|
|
116
|
+
path;
|
|
117
|
+
masterKey;
|
|
118
|
+
constructor(path, masterKey) {
|
|
119
|
+
this.path = path;
|
|
120
|
+
this.masterKey = masterKey;
|
|
121
|
+
}
|
|
122
|
+
load() {
|
|
123
|
+
if (!existsSync(this.path))
|
|
124
|
+
return { active: {}, spent: {} };
|
|
125
|
+
try {
|
|
126
|
+
const d = decryptToken(readFileSync(this.path, 'utf-8'), this.masterKey);
|
|
127
|
+
return { active: d.active ?? {}, spent: d.spent ?? {} };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { active: {}, spent: {} };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
save(data) {
|
|
134
|
+
// bound the spent set (drop oldest-inserted) so it can't grow forever.
|
|
135
|
+
const keys = Object.keys(data.spent);
|
|
136
|
+
if (keys.length > SPENT_CAP) {
|
|
137
|
+
for (const k of keys.slice(0, keys.length - SPENT_CAP))
|
|
138
|
+
delete data.spent[k];
|
|
139
|
+
}
|
|
140
|
+
atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
|
|
141
|
+
}
|
|
142
|
+
issue(nowMs, family) {
|
|
143
|
+
return withFileLock(this.path, () => {
|
|
144
|
+
const token = randomBytes(32).toString('base64url');
|
|
145
|
+
const data = this.load();
|
|
146
|
+
data.active[token] = { sub: 'owner', issuedAt: nowMs, family: family ?? randomBytes(12).toString('hex') };
|
|
147
|
+
this.save(data);
|
|
148
|
+
return token;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Rotate a presented refresh token; null if unknown OR if the presented
|
|
152
|
+
* token was already rotated away (reuse => the family is revoked). */
|
|
153
|
+
rotate(oldToken, nowMs) {
|
|
154
|
+
return withFileLock(this.path, () => {
|
|
155
|
+
const data = this.load();
|
|
156
|
+
const rec = data.active[oldToken];
|
|
157
|
+
if (!rec) {
|
|
158
|
+
// Reuse of a rotated-away token signals theft: revoke the whole family.
|
|
159
|
+
const fam = data.spent[oldToken];
|
|
160
|
+
if (fam) {
|
|
161
|
+
for (const [t, r] of Object.entries(data.active))
|
|
162
|
+
if (r.family === fam)
|
|
163
|
+
delete data.active[t];
|
|
164
|
+
this.save(data);
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
delete data.active[oldToken];
|
|
169
|
+
data.spent[oldToken] = rec.family;
|
|
170
|
+
const next = randomBytes(32).toString('base64url');
|
|
171
|
+
data.active[next] = { sub: 'owner', issuedAt: nowMs, family: rec.family };
|
|
172
|
+
this.save(data);
|
|
173
|
+
return next;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
|
|
3
|
+
* GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
|
|
4
|
+
* that changes nothing says so). Scope-profile and preference fields are
|
|
5
|
+
* migrated by their own slices' commands.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runMigrateConfig(env?: NodeJS.ProcessEnv): void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { CONFIG_VERSION, configFilePath, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
2
|
+
import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
3
|
+
function parseCsv(value) {
|
|
4
|
+
return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
|
|
8
|
+
* GOOGLE_ADMIN_ACCOUNTS). Never deletes or edits env; idempotent (a re-run
|
|
9
|
+
* that changes nothing says so). Scope-profile and preference fields are
|
|
10
|
+
* migrated by their own slices' commands.
|
|
11
|
+
*/
|
|
12
|
+
export function runMigrateConfig(env = process.env) {
|
|
13
|
+
const raw = env.GOOGLE_ACCOUNTS;
|
|
14
|
+
if (!raw || raw.trim() === '') {
|
|
15
|
+
console.log('GOOGLE_ACCOUNTS is not set — nothing to migrate.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const adminAliases = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
|
|
19
|
+
const desired = {};
|
|
20
|
+
const entries = raw.split(',');
|
|
21
|
+
for (let i = 0; i < entries.length; i++) {
|
|
22
|
+
const trimmed = entries[i].trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
continue;
|
|
25
|
+
const colonIdx = trimmed.indexOf(':');
|
|
26
|
+
if (colonIdx === -1) {
|
|
27
|
+
console.error(`Skipping GOOGLE_ACCOUNTS entry ${i + 1}: expected alias:email.`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const alias = trimmed.slice(0, colonIdx).trim();
|
|
31
|
+
const email = trimmed.slice(colonIdx + 1).trim();
|
|
32
|
+
if (!alias || !email || !/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
|
33
|
+
console.error(`Skipping GOOGLE_ACCOUNTS entry ${i + 1}: empty or invalid alias/email.`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
desired[alias] = { email };
|
|
37
|
+
}
|
|
38
|
+
// Legacy global scopes (BC7) become an explicit named profile every account
|
|
39
|
+
// points at, so removing the env var later changes nothing.
|
|
40
|
+
const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
|
|
41
|
+
let legacyProfile;
|
|
42
|
+
if (legacyNames.length > 0) {
|
|
43
|
+
const bundles = resolveBundleAliases(legacyNames);
|
|
44
|
+
for (const bundle of bundles) {
|
|
45
|
+
if (bundle === 'admin') {
|
|
46
|
+
console.error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: use GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile. Nothing written.');
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!(bundle in BUNDLE_CATALOG)) {
|
|
51
|
+
const hint = closestBundle(bundle);
|
|
52
|
+
console.error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}. Nothing written.`);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
legacyProfile = { bundles };
|
|
58
|
+
}
|
|
59
|
+
const filePath = configFilePath(env);
|
|
60
|
+
const before = loadConfigFile(filePath);
|
|
61
|
+
// Merge per-alias: env owns the alias set and emails, but file-only fields
|
|
62
|
+
// (scopeProfile, admin) survive for retained aliases — mirroring runtime
|
|
63
|
+
// precedence, where unset GOOGLE_ADMIN_ACCOUNTS preserves file admin flags.
|
|
64
|
+
const existing = before?.accounts ?? {};
|
|
65
|
+
const dropped = Object.keys(existing).filter((a) => !(a in desired));
|
|
66
|
+
for (const [alias, entry] of Object.entries(desired)) {
|
|
67
|
+
const prev = existing[alias];
|
|
68
|
+
if (legacyProfile)
|
|
69
|
+
entry.scopeProfile = 'legacy-global';
|
|
70
|
+
else if (prev?.scopeProfile)
|
|
71
|
+
entry.scopeProfile = prev.scopeProfile;
|
|
72
|
+
if (adminAliases.length > 0) {
|
|
73
|
+
if (adminAliases.includes(alias))
|
|
74
|
+
entry.admin = true;
|
|
75
|
+
}
|
|
76
|
+
else if (prev?.admin) {
|
|
77
|
+
entry.admin = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const beforeAccounts = JSON.stringify(existing);
|
|
81
|
+
const beforeProfiles = JSON.stringify(before?.scopeProfiles ?? {});
|
|
82
|
+
const desiredProfiles = legacyProfile
|
|
83
|
+
? { ...(before?.scopeProfiles ?? {}), 'legacy-global': legacyProfile }
|
|
84
|
+
: (before?.scopeProfiles ?? {});
|
|
85
|
+
if (beforeAccounts === JSON.stringify(desired) && beforeProfiles === JSON.stringify(desiredProfiles)) {
|
|
86
|
+
console.log(`${filePath} already matches the environment — nothing to do.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
mutateConfigFile((current) => {
|
|
90
|
+
current.version = current.version || CONFIG_VERSION;
|
|
91
|
+
current.accounts = desired;
|
|
92
|
+
if (legacyProfile)
|
|
93
|
+
current.scopeProfiles = desiredProfiles;
|
|
94
|
+
return current;
|
|
95
|
+
}, filePath);
|
|
96
|
+
if (dropped.length > 0) {
|
|
97
|
+
console.error(`Dropping ${dropped.length} alias(es) present in config.json but absent from GOOGLE_ACCOUNTS.`);
|
|
98
|
+
}
|
|
99
|
+
console.log(`Wrote ${filePath}:`);
|
|
100
|
+
console.log(` accounts before: ${beforeAccounts}`);
|
|
101
|
+
console.log(` accounts after: ${JSON.stringify(desired)}`);
|
|
102
|
+
console.log('Environment variables were NOT modified; while GOOGLE_ACCOUNTS is set, env still wins.');
|
|
103
|
+
}
|
package/dist/migrate-tokens.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { getAccountSet } from './accounts.js';
|
|
3
3
|
import { writeToken, hasToken } from './token-store.js';
|
|
4
4
|
export function runMigrateTokens() {
|
|
5
5
|
let migrated = 0;
|
|
6
6
|
let skipped = 0;
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const { aliases, configs } = getAccountSet();
|
|
8
|
+
for (const alias of aliases) {
|
|
9
|
+
const plain = configs[alias].tokenPath;
|
|
9
10
|
if (!fs.existsSync(plain))
|
|
10
11
|
continue;
|
|
11
12
|
if (hasToken(alias)) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
|
|
2
|
+
/** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
|
|
3
|
+
export declare function decideConnectAttemptTimeout(opts: {
|
|
4
|
+
execArgv: readonly string[];
|
|
5
|
+
nodeOptions: string | undefined;
|
|
6
|
+
current: number;
|
|
7
|
+
}): number | null;
|
|
8
|
+
export declare function applyNetTuning(): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
// Node's happy-eyeballs gives each address family 250ms per connect attempt on
|
|
3
|
+
// every LTS line (raised to 500ms only in v25.2+); on high-latency or
|
|
4
|
+
// broken-IPv6 links that aborts EVERY Google call while curl works. Raise the
|
|
5
|
+
// process default unless the user tuned it themselves. Why: docs/internals.md.
|
|
6
|
+
export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
|
|
7
|
+
const USER_FLAGS = ['--network-family-autoselection-attempt-timeout', '--no-network-family-autoselection'];
|
|
8
|
+
/** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
|
|
9
|
+
export function decideConnectAttemptTimeout(opts) {
|
|
10
|
+
const userArgs = [...opts.execArgv, opts.nodeOptions ?? ''].join(' ');
|
|
11
|
+
if (USER_FLAGS.some((f) => userArgs.includes(f)))
|
|
12
|
+
return null;
|
|
13
|
+
if (opts.current >= CONNECT_ATTEMPT_TIMEOUT_MS)
|
|
14
|
+
return null;
|
|
15
|
+
return CONNECT_ATTEMPT_TIMEOUT_MS;
|
|
16
|
+
}
|
|
17
|
+
export function applyNetTuning() {
|
|
18
|
+
const timeout = decideConnectAttemptTimeout({
|
|
19
|
+
execArgv: process.execArgv,
|
|
20
|
+
nodeOptions: process.env.NODE_OPTIONS,
|
|
21
|
+
current: net.getDefaultAutoSelectFamilyAttemptTimeout(),
|
|
22
|
+
});
|
|
23
|
+
if (timeout !== null)
|
|
24
|
+
net.setDefaultAutoSelectFamilyAttemptTimeout(timeout);
|
|
25
|
+
}
|