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

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.
@@ -80,40 +80,78 @@ export declare class ReplayGuard {
80
80
  get size(): number;
81
81
  private evictExpired;
82
82
  }
83
+ /** An earlier release's record, keyed by its live token. */
83
84
  export interface RefreshRecord {
84
85
  sub: string;
85
86
  issuedAt: number;
86
87
  family: string;
87
88
  }
88
89
  /**
89
- * Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
90
- * every use (a stolen token is usable at most once), and detects REUSE of a
91
- * rotated-away token as theft: the whole token family is revoked (#7 / C14).
92
- * All read-modify-write goes through a file lock so concurrent stdio+HTTP
93
- * processes can't lost-update or double-spend (#12).
90
+ * The family tag of a refresh token this `masterKey`'s store minted, or null
91
+ * for anything else. Derives the key once; the returned function does no I/O,
92
+ * so a host can key a pre-auth rate limit on it.
93
+ */
94
+ export declare function refreshFamilyTagger(masterKey: string): (token: string) => string | null;
95
+ /** Boot check for a refresh store file: returns when it is absent or this
96
+ * release can read it; throws when it does not decrypt under `masterKey`
97
+ * (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
98
+ * (E_REFRESH_STORE_FORMAT). */
99
+ export declare function assertRefreshStoreReadable(file: string, masterKey: string): void;
100
+ export interface RefreshStoreOptions {
101
+ /** Absolute family lifetime in seconds, counted from the sign-in. Unset: unlimited. */
102
+ maxAgeSec?: number;
103
+ /** Idle lifetime in seconds, counted from the last issue or rotation. Unset: unlimited. */
104
+ idleSec?: number;
105
+ /** Server-side log for a revoked, dropped or expired family. Never given a token or a subject. */
106
+ log?: (line: string) => void;
107
+ }
108
+ /**
109
+ * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
110
+ * use, and a token carries its family and generation under a MAC, so
111
+ * presenting any generation the family has rotated past is REUSE: the family
112
+ * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
113
+ * record per family, whatever its rotation count. Every read-modify-write goes
114
+ * through a file lock so concurrent stdio+HTTP processes can't lost-update or
115
+ * double-spend (#12). See docs/internals.md.
94
116
  */
95
117
  export declare class RefreshStore {
96
118
  private readonly path;
97
119
  private readonly masterKey;
98
- constructor(path: string, masterKey: string);
120
+ private readonly log;
121
+ private readonly maxAgeMs;
122
+ private readonly idleMs;
123
+ private macKey;
124
+ private legacyEmpty;
125
+ constructor(path: string, masterKey: string, options?: RefreshStoreOptions);
126
+ private get timed();
127
+ private live;
128
+ /** Drop expired sessions in both formats; the count of those dropped. */
129
+ private prune;
130
+ /** A session written while the clock ran ahead would otherwise outlive its
131
+ * limit by the size of the jump. */
132
+ private clamp;
133
+ private key;
134
+ /** With `nowMs` (every issue and rotation), also clamps and prunes. */
99
135
  private load;
100
136
  private save;
101
- issue(nowMs: number, sub: string, family?: string): string;
102
- /** Rotate a presented refresh token; null if unknown OR if the presented
103
- * token was already rotated away (reuse => the family is revoked). The
104
- * record's sub is copied forward and returned so the caller can mint the
105
- * matching access token without trusting anything client-supplied.
137
+ private emit;
138
+ private newFamilyId;
139
+ issue(nowMs: number, sub: string): string;
140
+ /** Rotate a presented refresh token; null if it is unknown, malformed, or
141
+ * rotated away (reuse => the family is revoked). The record's sub is copied
142
+ * forward and returned so the caller can mint the matching access token
143
+ * without trusting anything client-supplied.
106
144
  *
107
145
  * `accept` (optional) is called inside the store lock, before any mutation,
108
- * with the record's sub. false: every active record of that sub is dropped
109
- * and null returned. A throw: nothing is mutated and the error propagates,
110
- * so the presented token stays valid. Absent: unchanged. */
146
+ * with the record's sub. false: every session of that sub is dropped and
147
+ * null returned. A throw: nothing is written and the error propagates, so
148
+ * the presented token stays valid. Absent: unchanged. */
111
149
  rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean): {
112
150
  token: string;
113
151
  sub: string;
114
152
  } | null;
115
- /** Drop every ACTIVE record minted under `sub` (linear scan under the store
116
- * lock); the save then drops the spent entries of every family left with no
117
- * active token. Returns the number of active tokens dropped. */
153
+ /** Drop every session minted under `sub`, in both formats (linear scan under
154
+ * the store lock); the save then drops the earlier-release spent entries of
155
+ * the families that died with them. Returns the number of sessions dropped. */
118
156
  purgeTenant(sub: string): number;
119
157
  }
package/dist/mcp-token.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // B13 token model (oauth-authorization-server.md §Data model): the MCP access
2
2
  // token (HS256 JWT, jose-only — AS==RS so no JWKS), the signed self-contained
3
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
4
+ // the rotated refresh-token store. Keyed by the provisioned MCP_JWT_KEY
5
5
  // (B5), so tokens survive a restart as long as the key persists.
6
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
6
+ import { createHash, createHmac, hkdfSync, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
+ import { basename } from 'node:path';
8
9
  import { SignJWT, jwtVerify } from 'jose';
9
10
  import { deriveKey, encryptToken, decryptToken } from './token-store.js';
10
11
  import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
@@ -132,7 +133,80 @@ export class ReplayGuard {
132
133
  }
133
134
  }
134
135
  }
135
- const SPENT_CAP = 2000;
136
+ /** Store files this release writes; an absent `format` is an earlier release's. */
137
+ const REFRESH_FORMAT = 2;
138
+ // r1 token: "r1." + base64url(fid 16 || gen uint32 BE || rand 16 || HMAC-SHA256
139
+ // of those 36 bytes). The dot is outside the base64url alphabet, so an r1
140
+ // token can never be read as an earlier release's 43-character token.
141
+ const R1_PREFIX = 'r1.';
142
+ const R1_LENGTH = 94;
143
+ const R1_BODY = 36;
144
+ const R1_BYTES = 68;
145
+ const R1_MAC_DOMAIN = Buffer.from('mcp-google-multi refresh r1\n');
146
+ const R1_KEY_INFO = 'mcp-google-multi:refresh-mac:v1';
147
+ // Every r1 token carries a MAC under a key from MASTER_KEY, so a passphrase
148
+ // key (deriveKey's bare-sha256 branch) would let one leaked token test
149
+ // guesses offline at hash speed. scrypt gives that branch a work factor.
150
+ const R1_SCRYPT_SALT = 'mcp-google-multi:refresh-mac:scrypt:v1';
151
+ const R1_SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
152
+ const GEN_MAX = 0xffffffff;
153
+ const LEGACY_TOKEN = /^[A-Za-z0-9_-]{43}$/;
154
+ /** With a lifetime set, a stored time further ahead than this is pulled back to the clock. */
155
+ const CLOCK_SKEW_MS = 5 * 60_000;
156
+ const B64URL = /^[A-Za-z0-9_-]+$/;
157
+ const sha256 = (b) => createHash('sha256').update(b).digest();
158
+ /** A non-secret tag for log lines and rate-limit keys: never the token. */
159
+ const familyTag = (family) => sha256(family).toString('hex').slice(0, 8);
160
+ // Keyed on the master key: scrypt runs once per process, not once per store.
161
+ const macKeyMemo = new Map();
162
+ function refreshMacKey(masterKey) {
163
+ const hit = macKeyMemo.get(masterKey);
164
+ if (hit)
165
+ return hit;
166
+ const raw = deriveKey(masterKey);
167
+ const ikm = Buffer.from(masterKey, 'base64').length === 32 ? raw : scryptSync(masterKey, R1_SCRYPT_SALT, 32, R1_SCRYPT);
168
+ const key = Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), R1_KEY_INFO, 32));
169
+ macKeyMemo.set(masterKey, key);
170
+ return key;
171
+ }
172
+ function r1Mac(key, body) {
173
+ return createHmac('sha256', key).update(R1_MAC_DOMAIN).update(body).digest();
174
+ }
175
+ /** A token this key minted, or null. Pure CPU: runs before the file lock. */
176
+ function parseR1(t, key) {
177
+ if (typeof t !== 'string' || t.length !== R1_LENGTH || !t.startsWith(R1_PREFIX))
178
+ return null;
179
+ const text = t.slice(R1_PREFIX.length);
180
+ const buf = Buffer.from(text, 'base64url');
181
+ // The decoder skips stray characters and ignores the last one's pad bits;
182
+ // re-encoding leaves one spelling per token.
183
+ if (buf.length !== R1_BYTES || buf.toString('base64url') !== text)
184
+ return null;
185
+ if (!timingSafeEqual(buf.subarray(R1_BODY), r1Mac(key, buf.subarray(0, R1_BODY))))
186
+ return null;
187
+ const fidBytes = buf.subarray(0, 16);
188
+ return { fidBytes, fid: fidBytes.toString('base64url'), gen: buf.readUInt32BE(16), digest: sha256(buf) };
189
+ }
190
+ function mintR1(key, fid, gen) {
191
+ const body = Buffer.alloc(R1_BODY);
192
+ fid.copy(body, 0);
193
+ body.writeUInt32BE(gen, 16);
194
+ randomBytes(16).copy(body, 20);
195
+ const buf = Buffer.concat([body, r1Mac(key, body)]);
196
+ return { token: R1_PREFIX + buf.toString('base64url'), cur: sha256(buf).toString('base64url') };
197
+ }
198
+ /**
199
+ * The family tag of a refresh token this `masterKey`'s store minted, or null
200
+ * for anything else. Derives the key once; the returned function does no I/O,
201
+ * so a host can key a pre-auth rate limit on it.
202
+ */
203
+ export function refreshFamilyTagger(masterKey) {
204
+ const key = refreshMacKey(masterKey);
205
+ return (token) => {
206
+ const p = parseR1(token, key);
207
+ return p ? familyTag(p.fidBytes) : null;
208
+ };
209
+ }
136
210
  function ownEntries(o) {
137
211
  // An own `__proto__` key (JSON.parse makes one) would set the prototype when copied.
138
212
  return o !== null && typeof o === 'object' && !Array.isArray(o) ? Object.entries(o).filter(([k]) => k !== '__proto__') : [];
@@ -143,143 +217,349 @@ function isLegacyRecord(r) {
143
217
  const x = r;
144
218
  return typeof x.sub === 'string' && x.sub !== '' && typeof x.family === 'string' && typeof x.issuedAt === 'number' && Number.isFinite(x.issuedAt);
145
219
  }
220
+ function isCanonicalB64(s, bytes) {
221
+ return B64URL.test(s) && Buffer.from(s, 'base64url').length === bytes && Buffer.from(s, 'base64url').toString('base64url') === s;
222
+ }
223
+ function isFamilyRecord(key, r) {
224
+ if (r === null || typeof r !== 'object')
225
+ return false;
226
+ const x = r;
227
+ return (isCanonicalB64(key, 16) &&
228
+ typeof x.sub === 'string' && x.sub !== '' &&
229
+ Number.isInteger(x.gen) && x.gen >= 0 && x.gen <= GEN_MAX &&
230
+ typeof x.cur === 'string' && x.cur.length === 43 && isCanonicalB64(x.cur, 32) &&
231
+ Number.isFinite(x.createdAt) && Number.isFinite(x.usedAt) &&
232
+ (x.legacy === undefined || typeof x.legacy === 'string'));
233
+ }
234
+ function formatError(file, format) {
235
+ const name = basename(file);
236
+ return new Error(`E_REFRESH_STORE_FORMAT: ${name} was written by a newer release (format ${JSON.stringify(format)}): upgrade, or delete ${name} to sign every MCP client out`);
237
+ }
238
+ /** The decrypted plaintext, or null when the file is absent. Throws when it
239
+ * does not decrypt; the caller decides what that means. */
240
+ function readPlain(file, masterKey) {
241
+ if (!existsSync(file))
242
+ return null;
243
+ return decryptToken(readFileSync(file, 'utf-8'), masterKey);
244
+ }
245
+ // Outside any decrypt catch: a file from a newer release loaded as empty
246
+ // would be erased by the next write.
247
+ function checkFormat(file, d) {
248
+ if (d !== null && typeof d === 'object' && Object.hasOwn(d, 'format') && d.format !== REFRESH_FORMAT) {
249
+ throw formatError(file, d.format);
250
+ }
251
+ }
252
+ /** Only well-formed own entries survive, so no lookup lands on an inherited
253
+ * key and no malformed record reaches a liveness test or the token codec.
254
+ * `dirty` says something was dropped. */
255
+ function decodeData(d) {
256
+ const data = { families: {}, active: {}, spent: {} };
257
+ const x = (d ?? {});
258
+ let dropped = 0;
259
+ for (const [k, f] of ownEntries(x.families)) {
260
+ if (isFamilyRecord(k, f)) {
261
+ data.families[k] = { sub: f.sub, gen: f.gen, cur: f.cur, createdAt: f.createdAt, usedAt: f.usedAt, ...(f.legacy === undefined ? {} : { legacy: f.legacy }) };
262
+ }
263
+ else
264
+ dropped += 1;
265
+ }
266
+ for (const [t, r] of ownEntries(x.active)) {
267
+ if (isLegacyRecord(r))
268
+ data.active[t] = { sub: r.sub, issuedAt: r.issuedAt, family: r.family };
269
+ else
270
+ dropped += 1;
271
+ }
272
+ for (const [t, fam] of ownEntries(x.spent)) {
273
+ if (typeof fam === 'string')
274
+ data.spent[t] = fam;
275
+ else
276
+ dropped += 1;
277
+ }
278
+ return { data, dirty: dropped > 0 };
279
+ }
280
+ /** Every session of `sub`, in both formats. */
281
+ function dropSub(data, sub) {
282
+ let n = 0;
283
+ for (const [k, f] of Object.entries(data.families)) {
284
+ if (f.sub === sub) {
285
+ delete data.families[k];
286
+ n += 1;
287
+ }
288
+ }
289
+ for (const [t, r] of Object.entries(data.active)) {
290
+ if (r.sub === sub) {
291
+ delete data.active[t];
292
+ n += 1;
293
+ }
294
+ }
295
+ return n;
296
+ }
297
+ /** Boot check for a refresh store file: returns when it is absent or this
298
+ * release can read it; throws when it does not decrypt under `masterKey`
299
+ * (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
300
+ * (E_REFRESH_STORE_FORMAT). */
301
+ export function assertRefreshStoreReadable(file, masterKey) {
302
+ let d;
303
+ try {
304
+ d = readPlain(file, masterKey);
305
+ }
306
+ catch (e) {
307
+ throw new Error(`E_REFRESH_STORE_UNREADABLE: ${basename(file)} does not decrypt with this MASTER_KEY`, { cause: e });
308
+ }
309
+ checkFormat(file, d);
310
+ }
311
+ function lifetimeMs(name, v) {
312
+ if (v === undefined)
313
+ return undefined;
314
+ if (typeof v !== 'number' || !Number.isSafeInteger(v) || v <= 0)
315
+ throw new Error(`E_REFRESH_OPTION_INVALID: ${name} must be a positive whole number`);
316
+ return v * 1000;
317
+ }
146
318
  /**
147
- * Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
148
- * every use (a stolen token is usable at most once), and detects REUSE of a
149
- * rotated-away token as theft: the whole token family is revoked (#7 / C14).
150
- * All read-modify-write goes through a file lock so concurrent stdio+HTTP
151
- * processes can't lost-update or double-spend (#12).
319
+ * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
320
+ * use, and a token carries its family and generation under a MAC, so
321
+ * presenting any generation the family has rotated past is REUSE: the family
322
+ * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
323
+ * record per family, whatever its rotation count. Every read-modify-write goes
324
+ * through a file lock so concurrent stdio+HTTP processes can't lost-update or
325
+ * double-spend (#12). See docs/internals.md.
152
326
  */
153
327
  export class RefreshStore {
154
328
  path;
155
329
  masterKey;
156
- constructor(path, masterKey) {
330
+ log;
331
+ maxAgeMs;
332
+ idleMs;
333
+ macKey;
334
+ // Set once a load finds no earlier-release record. Nothing here adds one,
335
+ // so from then on a legacy-shaped token is refused before the lock.
336
+ legacyEmpty = false;
337
+ constructor(path, masterKey, options = {}) {
157
338
  this.path = path;
158
339
  this.masterKey = masterKey;
340
+ this.maxAgeMs = lifetimeMs('maxAgeSec', options.maxAgeSec);
341
+ this.idleMs = lifetimeMs('idleSec', options.idleSec);
342
+ this.log = options.log ?? (() => undefined);
343
+ }
344
+ get timed() {
345
+ return this.maxAgeMs !== undefined || this.idleMs !== undefined;
346
+ }
347
+ // Positive form: a non-finite time can only make a session dead.
348
+ live(createdAt, usedAt, nowMs) {
349
+ return (this.maxAgeMs === undefined || nowMs < createdAt + this.maxAgeMs) && (this.idleMs === undefined || nowMs < usedAt + this.idleMs);
350
+ }
351
+ /** Drop expired sessions in both formats; the count of those dropped. */
352
+ prune(data, nowMs, lines) {
353
+ if (!this.timed)
354
+ return 0;
355
+ let n = 0;
356
+ for (const [k, f] of Object.entries(data.families)) {
357
+ if (!this.live(f.createdAt, f.usedAt, nowMs)) {
358
+ delete data.families[k];
359
+ n += 1;
360
+ }
361
+ }
362
+ for (const [t, r] of Object.entries(data.active)) {
363
+ if (!this.live(r.issuedAt, r.issuedAt, nowMs)) {
364
+ delete data.active[t];
365
+ n += 1;
366
+ }
367
+ }
368
+ if (n > 0) {
369
+ lines.push(`refresh families expired n=${n}`);
370
+ if (Object.keys(data.families).length === 0 && Object.keys(data.active).length === 0)
371
+ lines.push('refresh store: every session expired at once (check the host clock)');
372
+ }
373
+ return n;
374
+ }
375
+ /** A session written while the clock ran ahead would otherwise outlive its
376
+ * limit by the size of the jump. */
377
+ clamp(data, nowMs, lines) {
378
+ if (!this.timed)
379
+ return false;
380
+ const limit = nowMs + CLOCK_SKEW_MS;
381
+ let n = 0;
382
+ const pull = (t) => {
383
+ if (t <= limit)
384
+ return t;
385
+ n += 1;
386
+ return nowMs;
387
+ };
388
+ for (const f of Object.values(data.families)) {
389
+ f.createdAt = pull(f.createdAt);
390
+ f.usedAt = pull(f.usedAt);
391
+ }
392
+ for (const r of Object.values(data.active))
393
+ r.issuedAt = pull(r.issuedAt);
394
+ if (n > 0)
395
+ lines.push(`refresh store clock: n=${n} timestamps ahead of the clock were clamped`);
396
+ return n > 0;
397
+ }
398
+ key() {
399
+ return (this.macKey ??= refreshMacKey(this.masterKey));
159
400
  }
160
- load() {
161
- const data = { active: {}, spent: {} };
162
- if (!existsSync(this.path))
163
- return data;
401
+ /** With `nowMs` (every issue and rotation), also clamps and prunes. */
402
+ load(nowMs, lines = []) {
164
403
  let d;
404
+ let readOk = true;
165
405
  try {
166
- d = decryptToken(readFileSync(this.path, 'utf-8'), this.masterKey);
406
+ d = readPlain(this.path, this.masterKey);
167
407
  }
168
408
  catch {
169
- return data;
409
+ d = null;
410
+ readOk = false;
170
411
  }
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 };
412
+ checkFormat(this.path, d);
413
+ const loaded = decodeData(d);
414
+ if (nowMs !== undefined) {
415
+ if (this.clamp(loaded.data, nowMs, lines))
416
+ loaded.dirty = true;
417
+ if (this.prune(loaded.data, nowMs, lines) > 0)
418
+ loaded.dirty = true;
176
419
  }
177
- for (const [t, fam] of ownEntries(d?.spent))
178
- if (typeof fam === 'string')
179
- data.spent[t] = fam;
180
- return data;
420
+ // A failed read is not evidence that the file holds no legacy record.
421
+ if (readOk && Object.keys(loaded.data.active).length === 0 && Object.keys(loaded.data.spent).length === 0)
422
+ this.legacyEmpty = true;
423
+ return loaded;
181
424
  }
182
425
  save(data) {
183
- // A family with no active token has nothing left to revoke. Over the cap,
184
- // the subject holding the most spent tokens loses its oldest (ties: the
185
- // oldest entry), so a subject's rotations, across all its families, only
186
- // push out its own history (docs/internals.md). Insertion order = rotation
187
- // order.
188
- const owner = new Map();
426
+ // Earlier-release evidence matters only while its family lives on, as a
427
+ // legacy record or as the family it migrated into.
428
+ const live = new Set();
189
429
  for (const r of Object.values(data.active))
190
- owner.set(r.family, r.sub);
191
- const order = Object.keys(data.spent);
192
- const held = new Map();
193
- let total = 0;
194
- order.forEach((t, i) => {
195
- const sub = owner.get(data.spent[t]);
196
- if (sub === undefined) {
430
+ live.add(r.family);
431
+ for (const f of Object.values(data.families))
432
+ if (f.legacy !== undefined)
433
+ live.add(f.legacy);
434
+ for (const [t, fam] of Object.entries(data.spent))
435
+ if (!live.has(fam))
197
436
  delete data.spent[t];
198
- return;
437
+ atomicWriteFileSync(this.path, encryptToken({ format: REFRESH_FORMAT, families: data.families, active: data.active, spent: data.spent }, this.masterKey), 0o600);
438
+ }
439
+ // After the write, so a failed save or a throwing accept logs nothing.
440
+ emit(lines) {
441
+ for (const line of lines) {
442
+ try {
443
+ this.log(line);
199
444
  }
200
- const h = held.get(sub);
201
- if (h)
202
- h.at.push(i);
203
- else
204
- held.set(sub, { at: [i], head: 0 });
205
- total += 1;
206
- });
207
- while (total > SPENT_CAP) {
208
- let most = { at: [], head: 0 };
209
- let mostN = 0;
210
- for (const h of held.values()) {
211
- const n = h.at.length - h.head;
212
- if (n > mostN || (n === mostN && n > 0 && h.at[h.head] < most.at[most.head])) {
213
- most = h;
214
- mostN = n;
215
- }
445
+ catch {
446
+ // The rotation is already on disk; a log sink must not lose its token.
216
447
  }
217
- delete data.spent[order[most.at[most.head++]]];
218
- total -= 1;
219
448
  }
220
- atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
221
449
  }
222
- issue(nowMs, sub, family) {
450
+ newFamilyId(data) {
451
+ let fid;
452
+ do
453
+ fid = randomBytes(16);
454
+ while (Object.hasOwn(data.families, fid.toString('base64url')));
455
+ return fid;
456
+ }
457
+ issue(nowMs, sub) {
458
+ if (typeof sub !== 'string' || sub === '')
459
+ throw new Error('RefreshStore.issue: sub must be a non-empty string');
460
+ const key = this.key();
223
461
  return withFileLock(this.path, () => {
224
- const token = randomBytes(32).toString('base64url');
225
- const data = this.load();
226
- data.active[token] = { sub, issuedAt: nowMs, family: family ?? randomBytes(12).toString('hex') };
462
+ const lines = [];
463
+ const { data } = this.load(nowMs, lines);
464
+ const fid = this.newFamilyId(data);
465
+ const { token, cur } = mintR1(key, fid, 0);
466
+ data.families[fid.toString('base64url')] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
227
467
  this.save(data);
468
+ this.emit(lines);
228
469
  return token;
229
470
  });
230
471
  }
231
- /** Rotate a presented refresh token; null if unknown OR if the presented
232
- * token was already rotated away (reuse => the family is revoked). The
233
- * record's sub is copied forward and returned so the caller can mint the
234
- * matching access token without trusting anything client-supplied.
472
+ /** Rotate a presented refresh token; null if it is unknown, malformed, or
473
+ * rotated away (reuse => the family is revoked). The record's sub is copied
474
+ * forward and returned so the caller can mint the matching access token
475
+ * without trusting anything client-supplied.
235
476
  *
236
477
  * `accept` (optional) is called inside the store lock, before any mutation,
237
- * with the record's sub. false: every active record of that sub is dropped
238
- * and null returned. A throw: nothing is mutated and the error propagates,
239
- * so the presented token stays valid. Absent: unchanged. */
478
+ * with the record's sub. false: every session of that sub is dropped and
479
+ * null returned. A throw: nothing is written and the error propagates, so
480
+ * the presented token stays valid. Absent: unchanged. */
240
481
  rotate(oldToken, nowMs, accept) {
482
+ const key = this.key();
483
+ const r1 = parseR1(oldToken, key);
484
+ if (!r1 && !(typeof oldToken === 'string' && LEGACY_TOKEN.test(oldToken) && !this.legacyEmpty))
485
+ return null;
241
486
  return withFileLock(this.path, () => {
242
- const data = this.load();
487
+ const lines = [];
488
+ // Expiry runs first: a dead session is refused without consulting accept.
489
+ const { data, dirty } = this.load(nowMs, lines);
490
+ const done = (result, write) => {
491
+ if (write)
492
+ this.save(data);
493
+ this.emit(lines);
494
+ return result;
495
+ };
496
+ const refuse = (sub) => {
497
+ lines.push(`refresh sessions dropped: accept n=${dropSub(data, sub)}`);
498
+ return done(null, true);
499
+ };
500
+ if (r1) {
501
+ const f = Object.hasOwn(data.families, r1.fid) ? data.families[r1.fid] : undefined;
502
+ if (!f)
503
+ return done(null, dirty);
504
+ const drop = (why) => {
505
+ delete data.families[r1.fid];
506
+ lines.push(`refresh family ${why === 'reuse' ? 'revoked' : 'dropped'}: ${why} tag=${familyTag(r1.fidBytes)}`);
507
+ return done(null, true);
508
+ };
509
+ // Older: a rotated-away token, and the MAC proves this server minted it.
510
+ if (r1.gen < f.gen)
511
+ return drop('reuse');
512
+ // Newer, or the same generation with another random part: the store
513
+ // lost a write the client saw. Not evidence of theft.
514
+ if (r1.gen > f.gen)
515
+ return drop('ahead');
516
+ if (!timingSafeEqual(r1.digest, Buffer.from(f.cur, 'base64url')))
517
+ return drop('mismatch');
518
+ if (accept && !accept(f.sub))
519
+ return refuse(f.sub);
520
+ if (f.gen === GEN_MAX)
521
+ return drop('overflow');
522
+ const next = mintR1(key, r1.fidBytes, f.gen + 1);
523
+ f.gen += 1;
524
+ f.cur = next.cur;
525
+ // now() is read before the lock wait, so a later writer can hold an earlier time.
526
+ f.usedAt = Math.max(f.usedAt, nowMs);
527
+ return done({ token: next.token, sub: f.sub }, true);
528
+ }
243
529
  const rec = Object.hasOwn(data.active, oldToken) ? data.active[oldToken] : undefined;
244
530
  if (!rec) {
245
- // Reuse of a rotated-away token signals theft: revoke the whole family.
246
531
  const fam = Object.hasOwn(data.spent, oldToken) ? data.spent[oldToken] : undefined;
247
- if (fam) {
248
- for (const [t, r] of Object.entries(data.active))
249
- if (r.family === fam)
250
- delete data.active[t];
251
- this.save(data);
252
- }
253
- return null;
254
- }
255
- if (accept && !accept(rec.sub)) {
532
+ if (fam === undefined)
533
+ return done(null, dirty);
534
+ // Reuse of an earlier-release rotated-away token: revoke the family in
535
+ // both formats.
256
536
  for (const [t, r] of Object.entries(data.active))
257
- if (r.sub === rec.sub)
537
+ if (r.family === fam)
258
538
  delete data.active[t];
259
- this.save(data);
260
- return null;
539
+ for (const [k, f] of Object.entries(data.families))
540
+ if (f.legacy === fam)
541
+ delete data.families[k];
542
+ lines.push(`refresh family revoked: reuse tag=${familyTag(fam)}`);
543
+ return done(null, true);
261
544
  }
545
+ if (accept && !accept(rec.sub))
546
+ return refuse(rec.sub);
547
+ // Migrate: the presented token stays behind as reuse evidence.
262
548
  delete data.active[oldToken];
263
549
  data.spent[oldToken] = rec.family;
264
- const next = randomBytes(32).toString('base64url');
265
- data.active[next] = { sub: rec.sub, issuedAt: nowMs, family: rec.family };
266
- this.save(data);
267
- return { token: next, sub: rec.sub };
550
+ const fid = this.newFamilyId(data);
551
+ const next = mintR1(key, fid, 0);
552
+ data.families[fid.toString('base64url')] = { sub: rec.sub, gen: 0, cur: next.cur, createdAt: rec.issuedAt, usedAt: Math.max(rec.issuedAt, nowMs), legacy: rec.family };
553
+ return done({ token: next.token, sub: rec.sub }, true);
268
554
  });
269
555
  }
270
- /** Drop every ACTIVE record minted under `sub` (linear scan under the store
271
- * lock); the save then drops the spent entries of every family left with no
272
- * active token. Returns the number of active tokens dropped. */
556
+ /** Drop every session minted under `sub`, in both formats (linear scan under
557
+ * the store lock); the save then drops the earlier-release spent entries of
558
+ * the families that died with them. Returns the number of sessions dropped. */
273
559
  purgeTenant(sub) {
274
560
  return withFileLock(this.path, () => {
275
- const data = this.load();
276
- let dropped = 0;
277
- for (const [t, r] of Object.entries(data.active)) {
278
- if (r.sub === sub) {
279
- delete data.active[t];
280
- dropped += 1;
281
- }
282
- }
561
+ const { data } = this.load();
562
+ const dropped = dropSub(data, sub);
283
563
  if (dropped > 0)
284
564
  this.save(data);
285
565
  return dropped;
@@ -20,6 +20,12 @@ export interface AuthServerConfig {
20
20
  accessTtlSec?: number;
21
21
  masterKey: string;
22
22
  refreshStorePath: string;
23
+ /** `maxAgeSec` of the refresh store buildAuthServer creates: sessions end
24
+ * this long after their sign-in. Unset: unlimited. */
25
+ refreshMaxAgeSec?: number;
26
+ /** `idleSec` of that store: sessions end this long after their last
27
+ * refresh. Unset: unlimited. */
28
+ refreshIdleSec?: number;
23
29
  /** Default true. The clientless `flow=alias_reauth` link names an alias with
24
30
  * no tenant binding, so a multi-tenant deployment sets false: with several
25
31
  * tenants an alias name alone is ambiguous and the branch becomes a
package/dist/oauth-as.js CHANGED
@@ -11,8 +11,8 @@ import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCo
11
11
  import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
12
12
  const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
13
13
  // DCR (/register) is public + unauthenticated, so its in-memory store must be
14
- // bounded like every sibling store (ReplayGuard cap, RefreshStore SPENT_CAP) or
15
- // a request loop OOMs the process. Cap client count (FIFO-evict the oldest) and
14
+ // bounded like every sibling store (the ReplayGuard cap) or a request loop
15
+ // OOMs the process. Cap client count (FIFO-evict the oldest) and
16
16
  // the per-request redirect_uris shape so one entry can't be arbitrarily large.
17
17
  export const DCR_MAX_CLIENTS = 1000;
18
18
  export const DCR_MAX_REDIRECT_URIS = 10;
@@ -180,10 +180,14 @@ export function buildAuthServer(config, deps = {}) {
180
180
  const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
181
181
  const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
182
182
  const replay = deps.replayGuard ?? new ReplayGuard();
183
- const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey);
184
183
  const registered = deps.registeredClients ?? new Map();
185
184
  const now = deps.now ?? (() => Date.now());
186
185
  const log = deps.log ?? (() => undefined);
186
+ // Silently dropping a lifetime the caller asked for would leave sessions unlimited.
187
+ if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined)) {
188
+ throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec and refreshIdleSec configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
189
+ }
190
+ const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, log });
187
191
  const nowSec = () => Math.floor(now() / 1000);
188
192
  const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
189
193
  const issHeader = { 'Cache-Control': 'no-store' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.63",
3
+ "version": "6.0.0-alpha.64",
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",