mcp-google-multi 6.0.0-beta.3 → 6.0.0-beta.5

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.
Files changed (126) hide show
  1. package/README.md +20 -6
  2. package/dist/accounts.d.ts +3 -3
  3. package/dist/accounts.js +18 -31
  4. package/dist/arg-normalize.js +15 -35
  5. package/dist/arg-strict.js +8 -28
  6. package/dist/auth.d.ts +1 -1
  7. package/dist/auth.js +5 -10
  8. package/dist/boot-gates.d.ts +1 -1
  9. package/dist/boot-gates.js +3 -5
  10. package/dist/cimd-cache.js +5 -11
  11. package/dist/client-config.d.ts +2 -2
  12. package/dist/client-config.js +8 -14
  13. package/dist/client.js +0 -0
  14. package/dist/compose.d.ts +8 -1
  15. package/dist/compose.js +11 -11
  16. package/dist/config-file.js +7 -12
  17. package/dist/discover.js +6 -12
  18. package/dist/discovery-client.js +14 -33
  19. package/dist/doctor.d.ts +3 -3
  20. package/dist/doctor.js +12 -18
  21. package/dist/env-load.js +2 -5
  22. package/dist/executor.js +2 -4
  23. package/dist/fanout.js +8 -17
  24. package/dist/fs-atomic.d.ts +0 -3
  25. package/dist/fs-atomic.js +104 -22
  26. package/dist/hint-provider.d.ts +4 -1
  27. package/dist/hint-provider.js +4 -10
  28. package/dist/http-config.d.ts +2 -2
  29. package/dist/http-config.js +3 -7
  30. package/dist/http-transport.d.ts +5 -5
  31. package/dist/http-transport.js +39 -92
  32. package/dist/identity.d.ts +2 -2
  33. package/dist/identity.js +6 -9
  34. package/dist/index.js +19 -31
  35. package/dist/master-key.d.ts +3 -3
  36. package/dist/master-key.js +11 -22
  37. package/dist/mcp-token.d.ts +40 -16
  38. package/dist/mcp-token.js +121 -44
  39. package/dist/metrics-cli.d.ts +1 -1
  40. package/dist/metrics-cli.js +1 -1
  41. package/dist/metrics-tap.js +2 -5
  42. package/dist/migrate-config.d.ts +1 -2
  43. package/dist/migrate-config.js +3 -5
  44. package/dist/net-tuning.js +2 -4
  45. package/dist/oauth-as.d.ts +7 -2
  46. package/dist/oauth-as.js +45 -78
  47. package/dist/oauth-consent.js +4 -10
  48. package/dist/outbound-allowlist.js +4 -9
  49. package/dist/reauth-hint.js +2 -5
  50. package/dist/registry-transfer.d.ts +1 -1
  51. package/dist/registry-transfer.js +5 -13
  52. package/dist/registry.d.ts +3 -9
  53. package/dist/registry.js +97 -76
  54. package/dist/scope-catalog.d.ts +5 -0
  55. package/dist/scope-catalog.js +17 -7
  56. package/dist/scope-observability.d.ts +2 -3
  57. package/dist/scope-observability.js +8 -14
  58. package/dist/services.d.ts +3 -0
  59. package/dist/services.js +5 -10
  60. package/dist/ssrf-guard.js +11 -22
  61. package/dist/tenant-purge.js +2 -4
  62. package/dist/token-store.js +0 -0
  63. package/dist/tools/_coerce.d.ts +4 -4
  64. package/dist/tools/_errors.js +22 -39
  65. package/dist/tools/_local-files.js +4 -8
  66. package/dist/tools/account-wizard.d.ts +4 -3
  67. package/dist/tools/account-wizard.js +48 -54
  68. package/dist/tools/accounts-tool.d.ts +8 -1
  69. package/dist/tools/accounts-tool.js +18 -7
  70. package/dist/tools/admin.js +8 -4
  71. package/dist/tools/analytics.js +32 -16
  72. package/dist/tools/calendar.d.ts +5 -0
  73. package/dist/tools/calendar.js +63 -7
  74. package/dist/tools/chat.js +2 -3
  75. package/dist/tools/contacts.js +19 -14
  76. package/dist/tools/docs.js +45 -21
  77. package/dist/tools/drive.js +72 -39
  78. package/dist/tools/forms.js +2 -3
  79. package/dist/tools/generated/gmail.js +0 -14
  80. package/dist/tools/generated/method-map.js +1 -1
  81. package/dist/tools/gmail-mime.d.ts +1 -1
  82. package/dist/tools/gmail-mime.js +7 -12
  83. package/dist/tools/gmail.d.ts +3 -3
  84. package/dist/tools/gmail.js +135 -52
  85. package/dist/tools/google-api.js +5 -7
  86. package/dist/tools/meet.js +2 -3
  87. package/dist/tools/searchconsole.js +2 -3
  88. package/dist/tools/sheets.d.ts +10 -0
  89. package/dist/tools/sheets.js +111 -5
  90. package/dist/tools/slides.js +2 -3
  91. package/dist/tools/tasks.js +2 -3
  92. package/dist/trim.js +2 -3
  93. package/dist/usage-metrics.js +5 -12
  94. package/dist/write-control.d.ts +4 -8
  95. package/dist/write-control.js +18 -6
  96. package/package.json +24 -22
  97. package/skills/google-workspace/SKILL.md +162 -0
  98. package/skills/google-workspace/reference/admin.md +105 -0
  99. package/skills/google-workspace/reference/analytics.md +70 -0
  100. package/skills/google-workspace/reference/appsmarket.md +5 -0
  101. package/skills/google-workspace/reference/calendar.md +175 -0
  102. package/skills/google-workspace/reference/chat.md +59 -0
  103. package/skills/google-workspace/reference/classroom.md +5 -0
  104. package/skills/google-workspace/reference/cloudidentity.md +5 -0
  105. package/skills/google-workspace/reference/cloudsearch.md +5 -0
  106. package/skills/google-workspace/reference/contacts.md +138 -0
  107. package/skills/google-workspace/reference/docs.md +395 -0
  108. package/skills/google-workspace/reference/drive.md +485 -0
  109. package/skills/google-workspace/reference/driveactivity.md +5 -0
  110. package/skills/google-workspace/reference/drivelabels.md +5 -0
  111. package/skills/google-workspace/reference/forms.md +92 -0
  112. package/skills/google-workspace/reference/gmail.md +305 -0
  113. package/skills/google-workspace/reference/groupsmigration.md +5 -0
  114. package/skills/google-workspace/reference/groupssettings.md +5 -0
  115. package/skills/google-workspace/reference/keep.md +5 -0
  116. package/skills/google-workspace/reference/licensing.md +5 -0
  117. package/skills/google-workspace/reference/meet.md +68 -0
  118. package/skills/google-workspace/reference/postmaster.md +5 -0
  119. package/skills/google-workspace/reference/reseller.md +5 -0
  120. package/skills/google-workspace/reference/script.md +5 -0
  121. package/skills/google-workspace/reference/searchconsole.md +128 -0
  122. package/skills/google-workspace/reference/sheets.md +430 -0
  123. package/skills/google-workspace/reference/slides.md +67 -0
  124. package/skills/google-workspace/reference/tasks.md +167 -0
  125. package/skills/google-workspace/reference/vault.md +5 -0
  126. package/skills/google-workspace/reference/workspaceevents.md +5 -0
@@ -5,10 +5,8 @@ import { createRequire } from 'node:module';
5
5
  import { configDir, failStartup } from './config-file.js';
6
6
  import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
7
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.
8
+ // Native optionalDependency, loaded lazily and synchronously (the token
9
+ // store's key read is sync); EVERY failure falls through to the 0600 file.
12
10
  const require_ = createRequire(import.meta.url);
13
11
  function systemKeychain(account) {
14
12
  try {
@@ -32,8 +30,7 @@ function systemKeychain(account) {
32
30
  }
33
31
  }
34
32
  // 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).
33
+ // read or write the developer's real OS keyring.
37
34
  let keychainFactory = systemKeychain;
38
35
  export function __setKeychainFactoryForTest(fn) {
39
36
  keychainFactory = fn ?? systemKeychain;
@@ -46,14 +43,8 @@ function anyEncFileUnder(dir) {
46
43
  return false;
47
44
  }
48
45
  }
49
- // Glob the token dir instead of iterating registered aliases: an orphan .enc
50
- // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
51
- // one client) must still stop a fresh key from being minted. TOKEN_STORE_PATH
52
- // may relocate the token dir outside configDir(), so scan it directly; the
53
- // recursive configDir() walk then covers BOTH the tenant-namespaced token root
54
- // (tenants/<id>) AND any top-level master-key-encrypted registry beside it
55
- // (e.g. a downstream tenant map / invite store). Minting a fresh MASTER_KEY
56
- // over any existing .enc bricks it irreversibly, so the scan must see them all.
46
+ // Globs the token dir rather than iterating registered aliases, so an orphan
47
+ // .enc still stops a fresh key from being minted. See docs/internals.md.
57
48
  function anyTokenFileExists() {
58
49
  return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
59
50
  }
@@ -98,7 +89,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
98
89
  const fromFile = readKeyFile(filePath);
99
90
  if (fromFile)
100
91
  return { key: fromFile, provenance: 'file' };
101
- // Generate-on-setup. The hard guard is cc-security Cgen: minting a fresh
92
+ // Generate-on-setup. The hard guard: minting a fresh
102
93
  // MASTER_KEY while <alias>.enc files exist would silently brick them all.
103
94
  if (hardGuard && hasAnyToken()) {
104
95
  fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
@@ -115,10 +106,8 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
115
106
  return { key: racedFile, provenance: 'file' };
116
107
  // Exactly randomBytes(32) base64: hits deriveKey's 32-byte fast path.
117
108
  const generated = randomBytes(32).toString('base64');
118
- // The 0600 file is ALWAYS written: on Linux the "keychain" can be the
119
- // kernel keyutils keyring, which does not survive a reboot — a key that
120
- // lives only there bricks every token at the next boot. The file is the
121
- // durable sink; byte-exact read-back gates any use of the key.
109
+ // The 0600 file is ALWAYS written (some keychain backends don't survive
110
+ // a reboot); byte-exact read-back gates any use of the key.
122
111
  atomicWriteFileSync(filePath, `${generated}\n`, 0o600);
123
112
  if (readKeyFile(filePath) !== generated) {
124
113
  fail('E_KEY_PROVISIONING_FAILED', `generated ${envName} could not be read back byte-exact from ${filePath}; refusing to encrypt anything with an unverified key.`);
@@ -151,7 +140,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
151
140
  }
152
141
  let cachedMaster = null;
153
142
  let cachedJwt = null;
154
- /** BR-1 single choke point: no other module may read the env vars directly.
143
+ /** Single choke point: no other module may read the env vars directly.
155
144
  * Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
156
145
  export function resolveMasterKey(deps) {
157
146
  if (deps)
@@ -189,7 +178,7 @@ export function resolveJwtKey(deps) {
189
178
  }
190
179
  let mirrored = false;
191
180
  /**
192
- * BR-5: after the FIRST successful token decrypt with an env-sourced key,
181
+ * After the FIRST successful token decrypt with an env-sourced key,
193
182
  * mirror it into the keychain (only when the keychain holds no entry) so the
194
183
  * key survives an env loss. Gating on a successful decrypt means a stale or
195
184
  * wrong env key is never mirrored. Best-effort: failures are doctor material,
@@ -211,7 +200,7 @@ export function noteSuccessfulDecrypt() {
211
200
  // Keychain backend unavailable; env stays the working source.
212
201
  }
213
202
  }
214
- /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
203
+ /** Reset: drop the generated MASTER_KEY material (0600 file + keychain
215
204
  * entry) so the next run re-provisions. Never removes an env-provided key (the
216
205
  * deployer owns that; `env:true` flags it so reset can warn). The caller MUST
217
206
  * have wiped every .enc token first — regenerating with tokens present would
@@ -88,7 +88,7 @@ export declare class ReplayGuard {
88
88
  reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
89
89
  /** Record a jti as spent. A full guard refuses instead of evicting a live
90
90
  * jti: a flood of spends would otherwise make a spent artifact usable again,
91
- * such as a callback the browser binding refused (H4 F1). `artifactExpMs`
91
+ * such as a callback the browser binding refused. `artifactExpMs`
92
92
  * keeps the record alive as long as the artifact itself, even when the clock
93
93
  * stepped back between its signing and this spend. */
94
94
  spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
@@ -107,12 +107,31 @@ export interface RefreshRecord {
107
107
  issuedAt: number;
108
108
  family: string;
109
109
  }
110
+ /** A gen-1-behind token presented within this long of the rotation that
111
+ * retired it, from the same client, can be a concurrent duplicate rather
112
+ * than theft. See docs/internals.md. */
113
+ export declare const REPLAY_WINDOW_MS = 30000;
114
+ /** The store's own MAC key for `masterKey`, memoized. Exported so a caller
115
+ * that never touches a RefreshStore (the AS computing a request's
116
+ * clientFingerprint) can key it the same way the store does. */
117
+ export declare function refreshMacKey(masterKey: string): Buffer;
110
118
  /**
111
119
  * The family tag of a refresh token this `masterKey`'s store minted, or null
112
120
  * for anything else. Derives the key once; the returned function does no I/O,
113
121
  * so a host can key a pre-auth rate limit on it.
114
122
  */
115
123
  export declare function refreshFamilyTagger(masterKey: string): (token: string) => string | null;
124
+ /**
125
+ * A correlation tag for the client presenting a refresh token:
126
+ * HMAC-SHA256(key, client_id + "\n" + user-agent + "\n" + address), truncated
127
+ * to 16 bytes and base64url-encoded. `key` is the store's own MAC key
128
+ * (refreshMacKey), so computing it needs MASTER_KEY: a reader of the store
129
+ * file alone (`by` is stored, not these inputs) cannot brute-force the
130
+ * client_id/user-agent/address that produced it. It is still not a secret:
131
+ * anyone who can see a live request can send the same inputs and get the
132
+ * same tag. Pure, no I/O.
133
+ */
134
+ export declare function clientFingerprint(clientId: string, userAgent: string, address: string, key: Buffer): string;
116
135
  /** Boot check for a refresh store file: returns when it is absent or this
117
136
  * release can read it; throws when it does not decrypt under `masterKey`
118
137
  * (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
@@ -138,10 +157,10 @@ export interface RefreshStoreOptions {
138
157
  * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
139
158
  * use, and a token carries its family and generation under a MAC, so
140
159
  * presenting any generation the family has rotated past is REUSE: the family
141
- * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
160
+ * is revoked for the rest of its life. The store keeps one
142
161
  * record per family, whatever its rotation count. Every read-modify-write goes
143
162
  * through a file lock so concurrent stdio+HTTP processes can't lost-update or
144
- * double-spend (#12). See docs/internals.md.
163
+ * double-spend. See docs/internals.md.
145
164
  */
146
165
  export declare class RefreshStore {
147
166
  private readonly path;
@@ -165,19 +184,15 @@ export declare class RefreshStore {
165
184
  private load;
166
185
  private save;
167
186
  private emit;
168
- /** Evict `sub`'s own least-recently-used sessions (both formats) so at
169
- * most `limit` of its live sessions remain; dropping one here is the same
170
- * as deleting it from `data.families` / `data.active` on a revoke, so it
171
- * can never be presented again (reuse detection for every OTHER family is
172
- * untouched). Only `sub`'s own entries are ever inspected.
173
- *
174
- * `keepKey` is the family just issued: it is excluded from the ranking so
175
- * a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
176
- * racing ahead of it under the lock, can never make the brand-new family
177
- * look like the least recently used one and drop itself. */
178
187
  private enforceFamilyCap;
179
188
  private newFamilyId;
180
- issue(nowMs: number, sub: string): string;
189
+ issue(nowMs: number, sub: string, fingerprint?: string): string;
190
+ /** A presented token exactly one generation behind `f`, inside the replay
191
+ * window, from the fingerprint that retired it, whose derived successor
192
+ * matches `f.cur`: a concurrent duplicate request rather than theft (see
193
+ * docs/internals.md). Null for anything else at that generation, including
194
+ * a different rand (a genuinely different gen-1 token) or no fingerprint. */
195
+ private concurrentDuplicate;
181
196
  /** Rotate a presented refresh token; null if it is unknown, malformed, or
182
197
  * rotated away (reuse => the family is revoked). The record's sub is copied
183
198
  * forward and returned so the caller can mint the matching access token
@@ -186,8 +201,17 @@ export declare class RefreshStore {
186
201
  * `accept` (optional) is called inside the store lock, before any mutation,
187
202
  * with the record's sub. false: every session of that sub is dropped and
188
203
  * null returned. A throw: nothing is written and the error propagates, so
189
- * the presented token stays valid. Absent: unchanged. */
190
- rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean): {
204
+ * the presented token stays valid. Absent: unchanged.
205
+ *
206
+ * `fingerprint` (optional) names the presenting client (clientFingerprint).
207
+ * Absent: a rotated-away token is always reuse, as before. Present: a
208
+ * token exactly one generation behind, from the same fingerprint, presented
209
+ * no earlier than and at most REPLAY_WINDOW_MS after the rotation that
210
+ * retired it, is a concurrent duplicate: it gets back the SAME successor
211
+ * token, with no change to the family (not even `usedAt`). Anything else
212
+ * rotated-away is still reuse, including a clock that has stepped back
213
+ * past `usedAt`, which cannot be told apart from a genuinely stale replay. */
214
+ rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean, fingerprint?: string): {
191
215
  token: string;
192
216
  sub: string;
193
217
  } | null;
package/dist/mcp-token.js CHANGED
@@ -1,8 +1,5 @@
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 rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
5
- // (B5), so tokens survive a restart as long as the key persists.
1
+ // Token model: the MCP access token (HS256 JWT, jose-only), signed state and
2
+ // authorization code, the replay guard, and the rotated refresh-token store.
6
3
  import { createHash, createHmac, hkdfSync, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
7
4
  import { existsSync, readFileSync } from 'node:fs';
8
5
  import { basename } from 'node:path';
@@ -126,7 +123,7 @@ export class ReplayGuard {
126
123
  }
127
124
  /** Record a jti as spent. A full guard refuses instead of evicting a live
128
125
  * jti: a flood of spends would otherwise make a spent artifact usable again,
129
- * such as a callback the browser binding refused (H4 F1). `artifactExpMs`
126
+ * such as a callback the browser binding refused. `artifactExpMs`
130
127
  * keeps the record alive as long as the artifact itself, even when the clock
131
128
  * stepped back between its signing and this spend. */
132
129
  spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
@@ -165,10 +162,8 @@ export class ReplayGuard {
165
162
  if (exp < this.earliest)
166
163
  this.earliest = exp;
167
164
  }
168
- // Expired entries are dropped only here, when the map is at the cap: a refusal
169
- // costs O(1), and a sweep runs only once something can have expired, at most
170
- // once a second. Walking the map from its head on every call is not cheap: a
171
- // Map iterator steps over every deleted slot until the table is rebuilt.
165
+ // Swept only when at the cap (a refusal otherwise costs O(1)), and at most
166
+ // once a second: a Map iterator steps over every deleted slot until rebuilt.
172
167
  room(nowMs) {
173
168
  if (this.seen.size < this.cap)
174
169
  return true;
@@ -190,31 +185,42 @@ export class ReplayGuard {
190
185
  }
191
186
  /** Store files this release writes; an absent `format` is an earlier release's. */
192
187
  const REFRESH_FORMAT = 2;
193
- // r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256
194
- // of those 36 bytes). The dot is outside the base64url alphabet, so an r1
195
- // token can never be read as an earlier release's 43-character token.
188
+ // r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256).
189
+ // The dot is outside the base64url alphabet, so it can't be read as a legacy 43-char token.
196
190
  const R1_PREFIX = 'r1.';
197
191
  const R1_LENGTH = 94;
198
192
  const R1_BODY = 36;
199
193
  const R1_BYTES = 68;
200
194
  const R1_MAC_DOMAIN = Buffer.from('mcp-google-multi refresh r1\n');
195
+ // Domain-separated from R1_MAC_DOMAIN: a successor derived under this key
196
+ // must never verify as a current token's own MAC, or vice versa.
197
+ const R1_SUCCESSOR_DOMAIN = Buffer.from('mcp-google-multi refresh r1 successor\n');
198
+ // Domain-separated from both of the above: a fingerprint must never verify as
199
+ // either a token MAC or a successor derivation under the same key.
200
+ const R1_FINGERPRINT_DOMAIN = Buffer.from('mcp-google-multi refresh r1 fingerprint\n');
201
201
  const R1_KEY_INFO = 'mcp-google-multi:refresh-mac:v1';
202
- // Every r1 token carries a MAC under a key from MASTER_KEY, so a passphrase
203
- // key (deriveKey's bare-sha256 branch) would let one leaked token test
204
- // guesses offline at hash speed. scrypt gives that branch a work factor.
202
+ // A passphrase key (deriveKey's bare-sha256 branch) would otherwise let one
203
+ // leaked r1 token test guesses offline at hash speed; scrypt adds a work factor.
205
204
  const R1_SCRYPT_SALT = 'mcp-google-multi:refresh-mac:scrypt:v1';
206
205
  const R1_SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
207
206
  const GEN_MAX = 0xffffffff;
208
207
  const LEGACY_TOKEN = /^[A-Za-z0-9_-]{43}$/;
209
208
  /** With a lifetime set, a stored time further ahead than this is pulled back to the clock. */
210
209
  const CLOCK_SKEW_MS = 5 * 60_000;
210
+ /** A gen-1-behind token presented within this long of the rotation that
211
+ * retired it, from the same client, can be a concurrent duplicate rather
212
+ * than theft. See docs/internals.md. */
213
+ export const REPLAY_WINDOW_MS = 30_000;
211
214
  const B64URL = /^[A-Za-z0-9_-]+$/;
212
215
  const sha256 = (b) => createHash('sha256').update(b).digest();
213
216
  /** A non-secret tag for log lines and rate-limit keys: never the token. */
214
217
  const familyTag = (family) => sha256(family).toString('hex').slice(0, 8);
215
218
  // Keyed on the master key: scrypt runs once per process, not once per store.
216
219
  const macKeyMemo = new Map();
217
- function refreshMacKey(masterKey) {
220
+ /** The store's own MAC key for `masterKey`, memoized. Exported so a caller
221
+ * that never touches a RefreshStore (the AS computing a request's
222
+ * clientFingerprint) can key it the same way the store does. */
223
+ export function refreshMacKey(masterKey) {
218
224
  const hit = macKeyMemo.get(masterKey);
219
225
  if (hit)
220
226
  return hit;
@@ -240,7 +246,7 @@ function parseR1(t, key) {
240
246
  if (!timingSafeEqual(buf.subarray(R1_BODY), r1Mac(key, buf.subarray(0, R1_BODY))))
241
247
  return null;
242
248
  const fidBytes = buf.subarray(0, 16);
243
- return { fidBytes, fid: fidBytes.toString('base64url'), gen: buf.readUInt32BE(16), digest: sha256(buf) };
249
+ return { fidBytes, fid: fidBytes.toString('base64url'), gen: buf.readUInt32BE(16), digest: sha256(buf), body: buf.subarray(0, R1_BODY) };
244
250
  }
245
251
  function mintR1(key, fid, gen) {
246
252
  const body = Buffer.alloc(R1_BODY);
@@ -250,6 +256,24 @@ function mintR1(key, fid, gen) {
250
256
  const buf = Buffer.concat([body, r1Mac(key, body)]);
251
257
  return { token: R1_PREFIX + buf.toString('base64url'), cur: sha256(buf).toString('base64url') };
252
258
  }
259
+ /**
260
+ * The token a rotation of `presentedBody` (a parsed token's fid||gen||rand)
261
+ * mints: generation + 1, and a random part derived (not drawn) from the
262
+ * presented body, so re-deriving it from a retired token and comparing the
263
+ * digest against a family's stored `cur` tells a concurrent duplicate from a
264
+ * different gen-1 token. Domain-separated from `r1Mac` so neither function's
265
+ * output can be read as the other's. See docs/internals.md.
266
+ */
267
+ function mintR1Successor(key, presentedBody) {
268
+ const rand = createHmac('sha256', key).update(R1_SUCCESSOR_DOMAIN).update(presentedBody).digest().subarray(0, 16);
269
+ const body = Buffer.alloc(R1_BODY);
270
+ presentedBody.copy(body, 0, 0, 16); // fid
271
+ body.writeUInt32BE(presentedBody.readUInt32BE(16) + 1, 16); // gen + 1
272
+ rand.copy(body, 20);
273
+ const buf = Buffer.concat([body, r1Mac(key, body)]);
274
+ const digest = sha256(buf);
275
+ return { token: R1_PREFIX + buf.toString('base64url'), cur: digest.toString('base64url'), digest };
276
+ }
253
277
  /**
254
278
  * The family tag of a refresh token this `masterKey`'s store minted, or null
255
279
  * for anything else. Derives the key once; the returned function does no I/O,
@@ -262,6 +286,24 @@ export function refreshFamilyTagger(masterKey) {
262
286
  return p ? familyTag(p.fidBytes) : null;
263
287
  };
264
288
  }
289
+ /**
290
+ * A correlation tag for the client presenting a refresh token:
291
+ * HMAC-SHA256(key, client_id + "\n" + user-agent + "\n" + address), truncated
292
+ * to 16 bytes and base64url-encoded. `key` is the store's own MAC key
293
+ * (refreshMacKey), so computing it needs MASTER_KEY: a reader of the store
294
+ * file alone (`by` is stored, not these inputs) cannot brute-force the
295
+ * client_id/user-agent/address that produced it. It is still not a secret:
296
+ * anyone who can see a live request can send the same inputs and get the
297
+ * same tag. Pure, no I/O.
298
+ */
299
+ export function clientFingerprint(clientId, userAgent, address, key) {
300
+ return createHmac('sha256', key).update(R1_FINGERPRINT_DOMAIN).update(`${clientId}\n${userAgent}\n${address}`).digest().subarray(0, 16).toString('base64url');
301
+ }
302
+ function timingSafeStringEqual(a, b) {
303
+ const ab = Buffer.from(a, 'utf-8');
304
+ const bb = Buffer.from(b, 'utf-8');
305
+ return ab.length === bb.length && timingSafeEqual(ab, bb);
306
+ }
265
307
  function ownEntries(o) {
266
308
  // An own `__proto__` key (JSON.parse makes one) would set the prototype when copied.
267
309
  return o !== null && typeof o === 'object' && !Array.isArray(o) ? Object.entries(o).filter(([k]) => k !== '__proto__') : [];
@@ -284,7 +326,8 @@ function isFamilyRecord(key, r) {
284
326
  Number.isInteger(x.gen) && x.gen >= 0 && x.gen <= GEN_MAX &&
285
327
  typeof x.cur === 'string' && x.cur.length === 43 && isCanonicalB64(x.cur, 32) &&
286
328
  Number.isFinite(x.createdAt) && Number.isFinite(x.usedAt) &&
287
- (x.legacy === undefined || typeof x.legacy === 'string'));
329
+ (x.legacy === undefined || typeof x.legacy === 'string') &&
330
+ (x.by === undefined || typeof x.by === 'string'));
288
331
  }
289
332
  function formatError(file, format) {
290
333
  const name = basename(file);
@@ -304,16 +347,19 @@ function checkFormat(file, d) {
304
347
  throw formatError(file, d.format);
305
348
  }
306
349
  }
307
- /** Only well-formed own entries survive, so no lookup lands on an inherited
308
- * key and no malformed record reaches a liveness test or the token codec.
309
- * `dirty` says something was dropped. */
350
+ // Only well-formed own entries survive, so no malformed record reaches a
351
+ // liveness test or the token codec; `dirty` says something was dropped.
310
352
  function decodeData(d) {
311
353
  const data = { families: {}, active: {}, spent: {} };
312
354
  const x = (d ?? {});
313
355
  let dropped = 0;
314
356
  for (const [k, f] of ownEntries(x.families)) {
315
357
  if (isFamilyRecord(k, f)) {
316
- data.families[k] = { sub: f.sub, gen: f.gen, cur: f.cur, createdAt: f.createdAt, usedAt: f.usedAt, ...(f.legacy === undefined ? {} : { legacy: f.legacy }) };
358
+ data.families[k] = {
359
+ sub: f.sub, gen: f.gen, cur: f.cur, createdAt: f.createdAt, usedAt: f.usedAt,
360
+ ...(f.legacy === undefined ? {} : { legacy: f.legacy }),
361
+ ...(f.by === undefined ? {} : { by: f.by }),
362
+ };
317
363
  }
318
364
  else
319
365
  dropped += 1;
@@ -378,10 +424,10 @@ function lifetimeMs(name, v) {
378
424
  * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
379
425
  * use, and a token carries its family and generation under a MAC, so
380
426
  * presenting any generation the family has rotated past is REUSE: the family
381
- * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
427
+ * is revoked for the rest of its life. The store keeps one
382
428
  * record per family, whatever its rotation count. Every read-modify-write goes
383
429
  * through a file lock so concurrent stdio+HTTP processes can't lost-update or
384
- * double-spend (#12). See docs/internals.md.
430
+ * double-spend. See docs/internals.md.
385
431
  */
386
432
  export class RefreshStore {
387
433
  path;
@@ -504,20 +550,12 @@ export class RefreshStore {
504
550
  this.log(line);
505
551
  }
506
552
  catch {
507
- // The rotation is already on disk; a log sink must not lose its token.
553
+ // log sink failure ignored
508
554
  }
509
555
  }
510
556
  }
511
- /** Evict `sub`'s own least-recently-used sessions (both formats) so at
512
- * most `limit` of its live sessions remain; dropping one here is the same
513
- * as deleting it from `data.families` / `data.active` on a revoke, so it
514
- * can never be presented again (reuse detection for every OTHER family is
515
- * untouched). Only `sub`'s own entries are ever inspected.
516
- *
517
- * `keepKey` is the family just issued: it is excluded from the ranking so
518
- * a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
519
- * racing ahead of it under the lock, can never make the brand-new family
520
- * look like the least recently used one and drop itself. */
557
+ // Evicts sub's own least-recently-used sessions down to `limit`; keepKey (the
558
+ // family just issued) is excluded from ranking. See docs/internals.md.
521
559
  enforceFamilyCap(data, sub, limit, lines, keepKey) {
522
560
  const entries = [];
523
561
  for (const [k, f] of Object.entries(data.families)) {
@@ -556,7 +594,7 @@ export class RefreshStore {
556
594
  while (Object.hasOwn(data.families, fid.toString('base64url')));
557
595
  return fid;
558
596
  }
559
- issue(nowMs, sub) {
597
+ issue(nowMs, sub, fingerprint) {
560
598
  if (typeof sub !== 'string' || sub === '')
561
599
  throw new Error('RefreshStore.issue: sub must be a non-empty string');
562
600
  const key = this.key();
@@ -566,7 +604,7 @@ export class RefreshStore {
566
604
  const fid = this.newFamilyId(data);
567
605
  const fidKey = fid.toString('base64url');
568
606
  const { token, cur } = mintR1(key, fid, 0);
569
- data.families[fidKey] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
607
+ data.families[fidKey] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs, ...(fingerprint === undefined ? {} : { by: fingerprint }) };
570
608
  if (this.maxFamiliesPerSub !== undefined)
571
609
  this.enforceFamilyCap(data, sub, this.maxFamiliesPerSub, lines, fidKey);
572
610
  this.save(data);
@@ -574,6 +612,25 @@ export class RefreshStore {
574
612
  return token;
575
613
  });
576
614
  }
615
+ /** A presented token exactly one generation behind `f`, inside the replay
616
+ * window, from the fingerprint that retired it, whose derived successor
617
+ * matches `f.cur`: a concurrent duplicate request rather than theft (see
618
+ * docs/internals.md). Null for anything else at that generation, including
619
+ * a different rand (a genuinely different gen-1 token) or no fingerprint. */
620
+ concurrentDuplicate(r1, f, nowMs, fingerprint, key) {
621
+ if (fingerprint === undefined || f.by === undefined)
622
+ return null;
623
+ if (r1.gen !== f.gen - 1)
624
+ return null;
625
+ if (nowMs < f.usedAt || nowMs - f.usedAt > REPLAY_WINDOW_MS)
626
+ return null;
627
+ if (!timingSafeStringEqual(fingerprint, f.by))
628
+ return null;
629
+ const succ = mintR1Successor(key, r1.body);
630
+ if (!timingSafeEqual(succ.digest, Buffer.from(f.cur, 'base64url')))
631
+ return null;
632
+ return { token: succ.token };
633
+ }
577
634
  /** Rotate a presented refresh token; null if it is unknown, malformed, or
578
635
  * rotated away (reuse => the family is revoked). The record's sub is copied
579
636
  * forward and returned so the caller can mint the matching access token
@@ -582,8 +639,17 @@ export class RefreshStore {
582
639
  * `accept` (optional) is called inside the store lock, before any mutation,
583
640
  * with the record's sub. false: every session of that sub is dropped and
584
641
  * null returned. A throw: nothing is written and the error propagates, so
585
- * the presented token stays valid. Absent: unchanged. */
586
- rotate(oldToken, nowMs, accept) {
642
+ * the presented token stays valid. Absent: unchanged.
643
+ *
644
+ * `fingerprint` (optional) names the presenting client (clientFingerprint).
645
+ * Absent: a rotated-away token is always reuse, as before. Present: a
646
+ * token exactly one generation behind, from the same fingerprint, presented
647
+ * no earlier than and at most REPLAY_WINDOW_MS after the rotation that
648
+ * retired it, is a concurrent duplicate: it gets back the SAME successor
649
+ * token, with no change to the family (not even `usedAt`). Anything else
650
+ * rotated-away is still reuse, including a clock that has stepped back
651
+ * past `usedAt`, which cannot be told apart from a genuinely stale replay. */
652
+ rotate(oldToken, nowMs, accept, fingerprint) {
587
653
  const key = this.key();
588
654
  const r1 = parseR1(oldToken, key);
589
655
  if (!r1 && !(typeof oldToken === 'string' && LEGACY_TOKEN.test(oldToken) && !this.legacyEmpty))
@@ -611,9 +677,16 @@ export class RefreshStore {
611
677
  lines.push(`refresh family ${why === 'reuse' ? 'revoked' : 'dropped'}: ${why} tag=${familyTag(r1.fidBytes)}`);
612
678
  return done(null, true);
613
679
  };
614
- // Older: a rotated-away token, and the MAC proves this server minted it.
615
- if (r1.gen < f.gen)
616
- return drop('reuse');
680
+ // Older: a rotated-away token verified by MAC as server-minted, unless a concurrent duplicate.
681
+ if (r1.gen < f.gen) {
682
+ const duplicate = this.concurrentDuplicate(r1, f, nowMs, fingerprint, key);
683
+ if (!duplicate)
684
+ return drop('reuse');
685
+ if (accept && !accept(f.sub))
686
+ return refuse(f.sub);
687
+ lines.push(`refresh family replayed tag=${familyTag(r1.fidBytes)}`);
688
+ return done({ token: duplicate.token, sub: f.sub }, dirty);
689
+ }
617
690
  // Newer, or the same generation with another random part: the store
618
691
  // lost a write the client saw. Not evidence of theft.
619
692
  if (r1.gen > f.gen)
@@ -624,11 +697,15 @@ export class RefreshStore {
624
697
  return refuse(f.sub);
625
698
  if (f.gen === GEN_MAX)
626
699
  return drop('overflow');
627
- const next = mintR1(key, r1.fidBytes, f.gen + 1);
700
+ const next = mintR1Successor(key, r1.body);
628
701
  f.gen += 1;
629
702
  f.cur = next.cur;
630
703
  // now() is read before the lock wait, so a later writer can hold an earlier time.
631
704
  f.usedAt = Math.max(f.usedAt, nowMs);
705
+ if (fingerprint === undefined)
706
+ delete f.by;
707
+ else
708
+ f.by = fingerprint;
632
709
  return done({ token: next.token, sub: f.sub }, true);
633
710
  }
634
711
  const rec = Object.hasOwn(data.active, oldToken) ? data.active[oldToken] : undefined;
@@ -15,7 +15,7 @@ interface PromotionRow {
15
15
  calls: number;
16
16
  kind: 'curation-candidate' | 'visibility-failure' | 'generation-gap';
17
17
  }
18
- /** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
18
+ /** The promotion-queue view: ranked evidence, a human decides. */
19
19
  export declare function classifyPromotion(merged: DayAgg, data: PromotionData): PromotionRow[];
20
20
  export declare function runMetricsCli(argv: string[], deps: MetricsCliDeps): number;
21
21
  export {};
@@ -87,7 +87,7 @@ function renderReport(merged, days, out) {
87
87
  }
88
88
  }
89
89
  const tail = (id) => id.split('.').slice(1).join('.');
90
- /** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
90
+ /** The promotion-queue view: ranked evidence, a human decides. */
91
91
  export function classifyPromotion(merged, data) {
92
92
  const curated = new Set();
93
93
  for (const id of data.curatedIds) {
@@ -1,10 +1,7 @@
1
1
  import { isSchemaValidationText } from './arg-normalize.js';
2
2
  const PENDING_CAP = 1_000;
3
- // The detector lives next to the rewriter that consumes the same frames, so
4
- // the two can never drift apart. This tap sees the ORIGINAL prose:
5
- // withValidationEnvelope sits BELOW it and converts that same frame into an
6
- // envelope for the client, so classification here and the contract on the wire
7
- // stay independent.
3
+ // This tap sees the ORIGINAL prose; withValidationEnvelope sits BELOW it and
4
+ // converts the same frame for the client, so the two stay independent.
8
5
  export { isSchemaValidationText };
9
6
  /**
10
7
  * @param isKnownTool membership test against the REGISTERED tool set. The
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
3
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.
4
+ * that changes nothing says so).
6
5
  */
7
6
  export declare function runMigrateConfig(env?: NodeJS.ProcessEnv): void;
@@ -6,8 +6,7 @@ function parseCsv(value) {
6
6
  /**
7
7
  * Synthesize config.json accounts{} from the v5 env (GOOGLE_ACCOUNTS +
8
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.
9
+ * that changes nothing says so).
11
10
  */
12
11
  export function runMigrateConfig(env = process.env) {
13
12
  const raw = env.GOOGLE_ACCOUNTS;
@@ -35,7 +34,7 @@ export function runMigrateConfig(env = process.env) {
35
34
  }
36
35
  desired[alias] = { email };
37
36
  }
38
- // Legacy global scopes (BC7) become an explicit named profile every account
37
+ // Legacy global scopes become an explicit named profile every account
39
38
  // points at, so removing the env var later changes nothing.
40
39
  const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
41
40
  let legacyProfile;
@@ -59,8 +58,7 @@ export function runMigrateConfig(env = process.env) {
59
58
  const filePath = configFilePath(env);
60
59
  const before = loadConfigFile(filePath);
61
60
  // 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.
61
+ // (scopeProfile, admin) survive for retained aliases.
64
62
  const existing = before?.accounts ?? {};
65
63
  const dropped = Object.keys(existing).filter((a) => !(a in desired));
66
64
  for (const [alias, entry] of Object.entries(desired)) {
@@ -1,8 +1,6 @@
1
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.
2
+ // Node's happy-eyeballs 250ms-per-attempt default aborts EVERY Google call on
3
+ // a high-latency or broken-IPv6 link while curl works. See docs/internals.md.
6
4
  export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;
7
5
  const USER_FLAGS = ['--network-family-autoselection-attempt-timeout', '--no-network-family-autoselection'];
8
6
  /** Pure decision: the timeout to apply, or null to leave Node's setting alone. */
@@ -60,7 +60,7 @@ export interface GoogleIdentity {
60
60
  sub?: string;
61
61
  hd?: string;
62
62
  }
63
- /** Only trust the id_token identity when Google marks the email verified (#8).
63
+ /** Only trust the id_token identity when Google marks the email verified.
64
64
  * The token comes straight from Google's token endpoint, so its claims are
65
65
  * read unsigned. A malformed sub or hd is dropped, never passed on. */
66
66
  export declare function verifiedIdentityFromIdToken(idToken?: string | null): GoogleIdentity | undefined;
@@ -91,6 +91,11 @@ export interface AuthServerDeps {
91
91
  }>;
92
92
  replayGuard?: ReplayGuard;
93
93
  refreshStore?: RefreshStore;
94
+ /** The presenting client's address, for the refresh-rotation fingerprint
95
+ * (clientFingerprint) that tells a concurrent duplicate request from a
96
+ * different holder of the same token. Default: the socket's remote
97
+ * address. A host behind a trusted proxy wires its own resolver here. */
98
+ clientAddress?: (req: IncomingMessage) => string | undefined;
94
99
  now?: () => number;
95
100
  /** Server-side log (never sent to callers) for auth failures / SSRF blocks. */
96
101
  log?: (line: string) => void;
@@ -146,7 +151,7 @@ export interface AuthServer {
146
151
  * start a re-auth nor learn which aliases exist or what they hold. */
147
152
  reauthLink: (alias: string) => string;
148
153
  }
149
- /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
154
+ /** Exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
150
155
  * the fixed claude.ai callback. */
151
156
  export declare function redirectAllowed(redirectUri: string, docRedirectUris: string[]): boolean;
152
157
  export declare function buildAuthServer(config: AuthServerConfig, deps?: AuthServerDeps): AuthServer;