mcp-google-multi 6.0.0-alpha.65 → 6.0.0-alpha.67

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.
@@ -100,7 +100,7 @@ export class CimdClientCache {
100
100
  // Over-cap requests arrive at whatever rate the caller likes.
101
101
  noteBusy() {
102
102
  const now = this.deps.now();
103
- if (now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
103
+ if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
104
104
  this.busySuppressed++;
105
105
  return;
106
106
  }
@@ -85,6 +85,7 @@ export declare class HttpTransportHost {
85
85
  private readonly depth;
86
86
  private readonly sockets;
87
87
  private readonly pending;
88
+ private readonly served;
88
89
  private readonly handling;
89
90
  private closing;
90
91
  private graceful?;
@@ -70,6 +70,10 @@ const MAX_GRACE_MS = 2 ** 31 - 1;
70
70
  function endAfterFlush(socket) {
71
71
  if (socket.destroyed || socket.writableEnded)
72
72
  return;
73
+ // Node arms a keep-alive timer when a response finishes and destroys the
74
+ // socket when it fires, which would cut a slow reader's unread tail just as
75
+ // an early destroy does. Only the client's FIN or the grace ends it now.
76
+ socket.setTimeout(0);
73
77
  socket.end();
74
78
  }
75
79
  // A request that arrives while closing is never answered, so Node keeps it
@@ -109,6 +113,9 @@ export class HttpTransportHost {
109
113
  // every handle() that has not settled.
110
114
  sockets = new Set();
111
115
  pending = new Map();
116
+ // Sockets that have accepted a request: nothing pending means the last
117
+ // response finished writing, but its tail can still sit in kernel buffers.
118
+ served = new WeakSet();
112
119
  handling = new Set();
113
120
  closing = false;
114
121
  graceful;
@@ -143,6 +150,7 @@ export class HttpTransportHost {
143
150
  stopReading(socket);
144
151
  return;
145
152
  }
153
+ this.served.add(socket);
146
154
  const entry = this.pending.get(socket);
147
155
  if (entry) {
148
156
  entry.count++;
@@ -232,7 +240,8 @@ export class HttpTransportHost {
232
240
  this.httpServer = undefined;
233
241
  return;
234
242
  }
235
- // Starting over would destroy every socket in its lingering close.
243
+ // Starting over would arm a second grace, and a shorter one would cut
244
+ // what the first still lets finish.
236
245
  this.graceful ??= this.closeGracefully(s, graceMs).finally(() => (this.graceful = undefined));
237
246
  return this.graceful;
238
247
  }
@@ -249,15 +258,19 @@ export class HttpTransportHost {
249
258
  // after the last of them: that one says so if it has not started, and the
250
259
  // settle hook in start() ends the socket once it finishes. Node itself
251
260
  // destroys a socket once a Connection: close response is flushed, which
252
- // resets it just as endAfterFlush explains, so that end lingers too.
261
+ // resets it just as endAfterFlush explains, so that end lingers too. A
262
+ // socket with nothing pending may still be delivering the tail of its
263
+ // last response, so only one that never accepted a request is destroyed.
253
264
  for (const socket of this.sockets) {
254
265
  const entry = this.pending.get(socket);
255
- if (!entry) {
266
+ if (!entry && !this.served.has(socket)) {
256
267
  socket.destroy();
257
268
  continue;
258
269
  }
259
270
  socket.destroySoon = () => endAfterFlush(socket);
260
- if (!entry.last.headersSent)
271
+ if (!entry)
272
+ endAfterFlush(socket);
273
+ else if (!entry.last.headersSent)
261
274
  entry.last.setHeader('Connection', 'close');
262
275
  }
263
276
  let timer;
@@ -1,7 +1,7 @@
1
1
  export declare const ACCESS_TTL_DEFAULT = 600;
2
2
  export declare const STATE_TTL_DEFAULT = 600;
3
3
  export declare const CODE_TTL_DEFAULT = 60;
4
- export declare const REPLAY_CAP_DEFAULT = 10000;
4
+ export declare const REPLAY_CAP_DEFAULT = 100000;
5
5
  /** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
6
6
  export declare function jwtSecretFrom(jwtKey: string): Uint8Array;
7
7
  export interface AccessTokenParams {
@@ -46,8 +46,9 @@ export interface CodePayload {
46
46
  sub: string;
47
47
  }
48
48
  export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
49
- export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
49
+ export declare function verifyState(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
50
50
  jti: string;
51
+ exp: number;
51
52
  }>;
52
53
  /** A re-auth link is handed out in a tool error, so it has to survive until
53
54
  * the user clicks it; an hour, not the 10-minute state TTL. */
@@ -64,21 +65,41 @@ export declare function verifyReauthLink(base: string, secret: Uint8Array, param
64
65
  /** A pending-authorization artifact for the DCR consent interstitial (same
65
66
  * shape as `state`, distinct purpose so the two can't be confused). */
66
67
  export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
67
- export declare function verifyPending(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
68
+ export declare function verifyPending(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
68
69
  jti: string;
70
+ exp: number;
69
71
  }>;
70
72
  export declare function signAuthzCode(payload: CodePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
71
- export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array): Promise<CodePayload & {
73
+ export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<CodePayload & {
72
74
  jti: string;
75
+ exp: number;
73
76
  }>;
77
+ /** `full`: there is no room without forgetting a jti that is still live. */
78
+ export type ReplaySpend = 'ok' | 'replay' | 'full';
74
79
  export declare class ReplayGuard {
75
80
  private readonly cap;
76
81
  private readonly seen;
82
+ private earliest;
83
+ private sweptAt;
77
84
  constructor(cap?: number);
78
- /** Record a jti as spent. Returns false if it was already spent (replay). */
85
+ /** Hold a slot for an artifact about to be handed out, so its spend can
86
+ * never meet a full guard. The flood then lands where the artifact is
87
+ * minted, and refusing there loses nothing. */
88
+ reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
89
+ /** Record a jti as spent. A full guard refuses instead of evicting a live
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`
92
+ * keeps the record alive as long as the artifact itself, even when the clock
93
+ * stepped back between its signing and this spend. */
94
+ spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
95
+ /** Undo a spend whose next step could not go ahead. The slot stays held, so
96
+ * nothing is forgotten, and the artifact can be spent once more. */
97
+ unspend(jti: string): void;
98
+ /** `spend` as a boolean: false for a replay and for a full guard alike. */
79
99
  consume(jti: string, ttlMs: number, nowMs: number): boolean;
80
100
  get size(): number;
81
- private evictExpired;
101
+ private record;
102
+ private room;
82
103
  }
83
104
  /** An earlier release's record, keyed by its live token. */
84
105
  export interface RefreshRecord {
package/dist/mcp-token.js CHANGED
@@ -12,7 +12,7 @@ import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
12
12
  export const ACCESS_TTL_DEFAULT = 600; // seconds
13
13
  export const STATE_TTL_DEFAULT = 600;
14
14
  export const CODE_TTL_DEFAULT = 60;
15
- export const REPLAY_CAP_DEFAULT = 10_000;
15
+ export const REPLAY_CAP_DEFAULT = 100_000;
16
16
  /** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
17
17
  export function jwtSecretFrom(jwtKey) {
18
18
  return new Uint8Array(deriveKey(jwtKey));
@@ -52,17 +52,19 @@ async function signArtifact(claims, purpose, base, secret, iat, ttlSec) {
52
52
  .setJti(newJti())
53
53
  .sign(secret);
54
54
  }
55
- async function verifyArtifact(token, purpose, base, secret) {
56
- const { payload } = await jwtVerify(token, secret, { issuer: base });
55
+ // `nowSec`: the caller's clock, so expiry is judged by the same clock as the
56
+ // replay guard that records the artifact's spend.
57
+ async function verifyArtifact(token, purpose, base, secret, nowSec) {
58
+ const { payload } = await jwtVerify(token, secret, { issuer: base, ...(nowSec !== undefined ? { currentDate: new Date(nowSec * 1000) } : {}) });
57
59
  if (payload.purpose !== purpose)
58
60
  throw new Error(`wrong artifact purpose (${String(payload.purpose)})`);
59
- return { ...payload, jti: String(payload.jti ?? '') };
61
+ return { ...payload, jti: String(payload.jti ?? ''), exp: Number(payload.exp ?? 0) };
60
62
  }
61
63
  export function signState(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
62
64
  return signArtifact(payload, 'mcp_state', base, secret, iat, ttlSec);
63
65
  }
64
- export async function verifyState(token, base, secret) {
65
- return (await verifyArtifact(token, 'mcp_state', base, secret));
66
+ export async function verifyState(token, base, secret, nowSec) {
67
+ return (await verifyArtifact(token, 'mcp_state', base, secret, nowSec));
66
68
  }
67
69
  // --- alias_reauth link -------------------------------------------------------
68
70
  /** A re-auth link is handed out in a tool error, so it has to survive until
@@ -93,44 +95,97 @@ export function verifyReauthLink(base, secret, params, nowSec) {
93
95
  export function signPending(payload, base, secret, iat, ttlSec = STATE_TTL_DEFAULT) {
94
96
  return signArtifact(payload, 'mcp_pending', base, secret, iat, ttlSec);
95
97
  }
96
- export async function verifyPending(token, base, secret) {
97
- return (await verifyArtifact(token, 'mcp_pending', base, secret));
98
+ export async function verifyPending(token, base, secret, nowSec) {
99
+ return (await verifyArtifact(token, 'mcp_pending', base, secret, nowSec));
98
100
  }
99
101
  export function signAuthzCode(payload, base, secret, iat, ttlSec = CODE_TTL_DEFAULT) {
100
102
  return signArtifact(payload, 'mcp_code', base, secret, iat, ttlSec);
101
103
  }
102
- export async function verifyAuthzCode(token, base, secret) {
103
- return (await verifyArtifact(token, 'mcp_code', base, secret));
104
+ export async function verifyAuthzCode(token, base, secret, nowSec) {
105
+ return (await verifyArtifact(token, 'mcp_code', base, secret, nowSec));
104
106
  }
105
- // --- Replay guard (C10/C17: single-use, capped, TTL-evicted) ----------------
107
+ /** A full guard refuses rather than sweeping again sooner than this. */
108
+ const FULL_SWEEP_EVERY_MS = 1000;
106
109
  export class ReplayGuard {
107
110
  cap;
108
- seen = new Map(); // jti -> expiry (ms)
111
+ seen = new Map();
112
+ // A lower bound on every entry's expiry: below it nothing can have expired.
113
+ earliest = Infinity;
114
+ sweptAt = -Infinity;
109
115
  constructor(cap = REPLAY_CAP_DEFAULT) {
110
116
  this.cap = cap;
111
117
  }
112
- /** Record a jti as spent. Returns false if it was already spent (replay). */
113
- consume(jti, ttlMs, nowMs) {
114
- this.evictExpired(nowMs);
115
- if (this.seen.has(jti))
116
- return false;
117
- if (this.seen.size >= this.cap) {
118
- // drop the oldest-inserted entry to bound memory (C17)
119
- const oldest = this.seen.keys().next().value;
120
- if (oldest !== undefined)
121
- this.seen.delete(oldest);
118
+ /** Hold a slot for an artifact about to be handed out, so its spend can
119
+ * never meet a full guard. The flood then lands where the artifact is
120
+ * minted, and refusing there loses nothing. */
121
+ reserve(jti, ttlMs, nowMs) {
122
+ if (!this.room(nowMs))
123
+ return 'full';
124
+ this.record(jti, nowMs + ttlMs, false);
125
+ return 'ok';
126
+ }
127
+ /** Record a jti as spent. A full guard refuses instead of evicting a live
128
+ * 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`
130
+ * keeps the record alive as long as the artifact itself, even when the clock
131
+ * stepped back between its signing and this spend. */
132
+ spend(jti, ttlMs, nowMs, artifactExpMs = 0) {
133
+ const exp = Math.max(nowMs + ttlMs, artifactExpMs);
134
+ const held = this.seen.get(jti);
135
+ if (held && held.exp > nowMs) {
136
+ if (held.spent)
137
+ return 'replay';
138
+ held.spent = true;
139
+ held.exp = Math.max(held.exp, exp);
140
+ return 'ok';
122
141
  }
123
- this.seen.set(jti, nowMs + ttlMs);
124
- return true;
142
+ if (held)
143
+ this.seen.delete(jti);
144
+ if (!this.room(nowMs))
145
+ return 'full';
146
+ this.record(jti, exp, true);
147
+ return 'ok';
148
+ }
149
+ /** Undo a spend whose next step could not go ahead. The slot stays held, so
150
+ * nothing is forgotten, and the artifact can be spent once more. */
151
+ unspend(jti) {
152
+ const held = this.seen.get(jti);
153
+ if (held)
154
+ held.spent = false;
155
+ }
156
+ /** `spend` as a boolean: false for a replay and for a full guard alike. */
157
+ consume(jti, ttlMs, nowMs) {
158
+ return this.spend(jti, ttlMs, nowMs) === 'ok';
125
159
  }
126
160
  get size() {
127
161
  return this.seen.size;
128
162
  }
129
- evictExpired(nowMs) {
130
- for (const [jti, exp] of this.seen) {
131
- if (exp <= nowMs)
163
+ record(jti, exp, spent) {
164
+ this.seen.set(jti, { exp, spent });
165
+ if (exp < this.earliest)
166
+ this.earliest = exp;
167
+ }
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.
172
+ room(nowMs) {
173
+ if (this.seen.size < this.cap)
174
+ return true;
175
+ if (nowMs < this.earliest)
176
+ return false;
177
+ if (nowMs >= this.sweptAt && nowMs - this.sweptAt < FULL_SWEEP_EVERY_MS)
178
+ return false;
179
+ this.sweptAt = nowMs;
180
+ let earliest = Infinity;
181
+ for (const [jti, e] of this.seen) {
182
+ if (e.exp <= nowMs)
132
183
  this.seen.delete(jti);
184
+ else if (e.exp < earliest)
185
+ earliest = e.exp;
133
186
  }
187
+ this.earliest = earliest;
188
+ return this.seen.size < this.cap;
134
189
  }
135
190
  }
136
191
  /** Store files this release writes; an absent `format` is an earlier release's. */
@@ -122,7 +122,9 @@ export interface AuthServer {
122
122
  /** Mint a signed, single-use, TTL-bound clientless flow URL
123
123
  * (`${base}/authorize?flow=...&state=<signed>`). The tenantId/bundles ride
124
124
  * INSIDE the signed state, so nothing at /authorize or /callback trusts a
125
- * caller-supplied identity. Inert unless something calls it. */
125
+ * caller-supplied identity. The URL works only on this AuthServer, and only
126
+ * until its process restarts: sign-in artifacts are signed with a key each
127
+ * buildAuthServer call generates. Inert unless something calls it. */
126
128
  mintFlowState: (opts: {
127
129
  flow: 'alias_add';
128
130
  alias: string;
package/dist/oauth-as.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // whole surface is exercised by an offline harness.
8
8
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
9
  import { isIPv4 } from 'node:net';
10
+ import { decodeJwt } from 'jose';
10
11
  import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
11
12
  import { fetchCimdDocument } from './ssrf-guard.js';
12
13
  import { CimdClientCache } from './cimd-cache.js';
@@ -53,8 +54,8 @@ function json(res, status, body, extraHeaders = {}) {
53
54
  res.end(JSON.stringify(body));
54
55
  return true;
55
56
  }
56
- function errorPage(res, status, slug, message) {
57
- res.writeHead(status, { 'Content-Type': 'text/plain' });
57
+ function errorPage(res, status, slug, message, extraHeaders = {}) {
58
+ res.writeHead(status, { 'Content-Type': 'text/plain', ...extraHeaders });
58
59
  res.end(`${slug}: ${message}`);
59
60
  return true;
60
61
  }
@@ -67,6 +68,10 @@ function subjectUnavailable(res) {
67
68
  function clientUnavailable(res) {
68
69
  return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
69
70
  }
71
+ // The replay guard is full: every slot holds a jti that is still live.
72
+ function signInBusy(res) {
73
+ return errorPage(res, 503, 'E_SIGNIN_BUSY', 'sign-in is busy right now; retry in a minute', { 'Retry-After': '60' });
74
+ }
70
75
  function redirect(res, location, extraHeaders = {}) {
71
76
  res.writeHead(302, { Location: location, ...extraHeaders });
72
77
  res.end();
@@ -183,6 +188,11 @@ export function buildAuthServer(config, deps = {}) {
183
188
  const base = config.base;
184
189
  const resource = config.resourceUri;
185
190
  const secret = config.secret;
191
+ // States, approvals and codes live minutes, and their single use rests on an
192
+ // in-memory replay guard that a restart empties. So they are signed with a
193
+ // key only this AuthServer holds: a restart invalidates every one in flight,
194
+ // instead of letting a spent or unreserved one pass the new, empty guard.
195
+ const flowSecret = new Uint8Array(randomBytes(32));
186
196
  const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
187
197
  const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
188
198
  const replay = deps.replayGuard ?? new ReplayGuard();
@@ -195,6 +205,26 @@ export function buildAuthServer(config, deps = {}) {
195
205
  }
196
206
  const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, log });
197
207
  const nowSec = () => Math.floor(now() / 1000);
208
+ // A full guard is refused at whatever rate the caller likes, so its line is
209
+ // written at most once a minute (or at once after the clock stepped back).
210
+ let fullLoggedAt = -Infinity;
211
+ let fullSuppressed = 0;
212
+ const noteFull = (at) => {
213
+ if (at >= fullLoggedAt && at - fullLoggedAt < 60_000) {
214
+ fullSuppressed++;
215
+ return;
216
+ }
217
+ log(`replay guard full: sign-in refused${fullSuppressed > 0 ? ` (n=${fullSuppressed} suppressed)` : ''}`);
218
+ fullLoggedAt = at;
219
+ fullSuppressed = 0;
220
+ };
221
+ const spend = (artifact, ttlSec) => {
222
+ const at = now();
223
+ const r = replay.spend(artifact.jti, ttlSec * 1000, at, artifact.exp * 1000);
224
+ if (r === 'full')
225
+ noteFull(at);
226
+ return r;
227
+ };
198
228
  const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
199
229
  const issHeader = { 'Cache-Control': 'no-store' };
200
230
  const prm = {
@@ -261,7 +291,9 @@ export function buildAuthServer(config, deps = {}) {
261
291
  function escapeHtml(s) {
262
292
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
263
293
  }
264
- async function toGoogle(res, sp) {
294
+ /** `incoming`: the artifact spent to get here. If no slot is left for the
295
+ * new state it is unspent, so retrying it works once a slot frees. */
296
+ async function toGoogle(res, sp, incoming) {
265
297
  // H4 F1: without this, a Google URL minted here and forwarded to someone
266
298
  // else signs THEM in and hands their subject to the starting client.
267
299
  const headers = {};
@@ -271,7 +303,16 @@ export function buildAuthServer(config, deps = {}) {
271
303
  sp = { ...sp, bind };
272
304
  headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
273
305
  }
274
- const state = await signState(sp, base, secret, nowSec());
306
+ const state = await signState(sp, base, flowSecret, nowSec());
307
+ // Its /callback must find a slot even while a flood fills the guard, or a
308
+ // refusal there (the browser binding) could not be recorded.
309
+ const at = now();
310
+ if (replay.reserve(String(decodeJwt(state).jti), STATE_TTL_DEFAULT * 1000, at) === 'full') {
311
+ if (incoming)
312
+ replay.unspend(incoming);
313
+ noteFull(at);
314
+ return signInBusy(res);
315
+ }
275
316
  const authUrl = deps.buildGoogleAuthUrl
276
317
  ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
277
318
  : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
@@ -290,22 +331,25 @@ export function buildAuthServer(config, deps = {}) {
290
331
  }
291
332
  let pending;
292
333
  try {
293
- pending = await verifyPending(form.pending ?? '', base, secret);
334
+ pending = await verifyPending(form.pending ?? '', base, flowSecret, nowSec());
294
335
  }
295
336
  catch {
296
337
  return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
297
338
  }
298
- // Only the client leg signs pendings, and it is always the owner sign-in;
299
- // a pending signed before that rule (flow=alias_reauth) is refused here.
339
+ // Only the client leg signs pendings, and it is always the owner sign-in.
340
+ // Defence in depth: a pending from an earlier release, which could carry
341
+ // flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
300
342
  if (pending.flow !== 'owner_gate') {
301
343
  return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
302
344
  }
303
- if (!replay.consume(pending.jti, STATE_TTL_DEFAULT * 1000, now())) {
345
+ const spent = spend(pending, STATE_TTL_DEFAULT);
346
+ if (spent === 'full')
347
+ return signInBusy(res);
348
+ if (spent === 'replay')
304
349
  return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
305
- }
306
350
  const { jti: _drop, ...sp } = pending;
307
351
  void _drop;
308
- return toGoogle(res, sp);
352
+ return toGoogle(res, sp, pending.jti);
309
353
  }
310
354
  const q = url.searchParams;
311
355
  const clientId = q.get('client_id') ?? '';
@@ -322,7 +366,7 @@ export function buildAuthServer(config, deps = {}) {
322
366
  if (q.get('flow') === 'alias_add' && !clientId) {
323
367
  let minted;
324
368
  try {
325
- minted = await verifyState(q.get('state') ?? '', base, secret);
369
+ minted = await verifyState(q.get('state') ?? '', base, flowSecret, nowSec());
326
370
  }
327
371
  catch {
328
372
  return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is invalid, expired, or tampered');
@@ -330,12 +374,14 @@ export function buildAuthServer(config, deps = {}) {
330
374
  if (minted.flow !== 'alias_add' || !minted.alias || !minted.tenantId) {
331
375
  return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is malformed');
332
376
  }
333
- if (!replay.consume(minted.jti, STATE_TTL_DEFAULT * 1000, now())) {
377
+ const spent = spend(minted, STATE_TTL_DEFAULT);
378
+ if (spent === 'full')
379
+ return signInBusy(res);
380
+ if (spent === 'replay')
334
381
  return errorPage(res, 400, 'E_STATE_INVALID', 'this add-account link was already used');
335
- }
336
382
  const { jti: _spent, ...sp } = minted;
337
383
  void _spent;
338
- return toGoogle(res, sp);
384
+ return toGoogle(res, sp, minted.jti);
339
385
  }
340
386
  // In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
341
387
  // client leg — no code is delivered to any client, the refreshed token is
@@ -393,7 +439,7 @@ export function buildAuthServer(config, deps = {}) {
393
439
  // it — their redirect_uris come from an issuer-allowlisted document a rogue
394
440
  // cannot forge, so there is nothing to spoof.
395
441
  if (client.dcr) {
396
- const pendingToken = await signPending(statePayload, base, secret, nowSec());
442
+ const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
397
443
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
398
444
  res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
399
445
  `<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
@@ -413,14 +459,16 @@ export function buildAuthServer(config, deps = {}) {
413
459
  const stateToken = url.searchParams.get('state') ?? '';
414
460
  let st;
415
461
  try {
416
- st = await verifyState(stateToken, base, secret);
462
+ st = await verifyState(stateToken, base, flowSecret, nowSec());
417
463
  }
418
464
  catch {
419
465
  return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
420
466
  }
421
- if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
467
+ const spent = spend(st, STATE_TTL_DEFAULT);
468
+ if (spent === 'full')
469
+ return signInBusy(res);
470
+ if (spent === 'replay')
422
471
  return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
423
- }
424
472
  // After the spend, so a refused callback URL cannot be replayed from the
425
473
  // browser that holds the cookie; before the exchange and resolveSubject.
426
474
  if (st.flow === 'owner_gate') {
@@ -528,7 +576,7 @@ export function buildAuthServer(config, deps = {}) {
528
576
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
529
577
  return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
530
578
  }
531
- const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, secret, nowSec());
579
+ const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, flowSecret, nowSec());
532
580
  log('callback ok flow=owner_gate');
533
581
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
534
582
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
@@ -547,14 +595,16 @@ export function buildAuthServer(config, deps = {}) {
547
595
  if (grant === 'authorization_code') {
548
596
  let code;
549
597
  try {
550
- code = await verifyAuthzCode(form.code ?? '', base, secret);
598
+ code = await verifyAuthzCode(form.code ?? '', base, flowSecret, nowSec());
551
599
  }
552
600
  catch {
553
601
  return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
554
602
  }
555
- if (!replay.consume(code.jti, CODE_TTL_DEFAULT * 1000, now())) {
603
+ const spent = spend(code, CODE_TTL_DEFAULT);
604
+ if (spent === 'full')
605
+ return subjectUnavailable(res);
606
+ if (spent === 'replay')
556
607
  return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
557
- }
558
608
  if (form.redirect_uri !== code.redirect_uri) {
559
609
  return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
560
610
  }
@@ -572,6 +622,8 @@ export function buildAuthServer(config, deps = {}) {
572
622
  }
573
623
  catch (e) {
574
624
  log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
625
+ // Nothing was issued and PKCE passed, so the retry the 503 asks for may redeem it.
626
+ replay.unspend(code.jti);
575
627
  return subjectUnavailable(res);
576
628
  }
577
629
  if (!active) {
@@ -684,7 +736,7 @@ export function buildAuthServer(config, deps = {}) {
684
736
  // here would be a state no route verifies the way its flow needs.
685
737
  if (flow !== 'alias_add')
686
738
  throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
687
- const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, secret, nowSec());
739
+ const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, flowSecret, nowSec());
688
740
  return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
689
741
  };
690
742
  const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.65",
3
+ "version": "6.0.0-alpha.67",
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",