@mathismeadows/roamer-device-auth 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "mcpName": "com.mathismeadows/roamer-mcp",
6
6
  "type": "module",
@@ -20,6 +20,18 @@
20
20
  // package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
21
21
  // version-pinned package via npx rather than running this file in place.
22
22
  //
23
+ // AUTH-51: every cached credential is namespaced per connecting MCP client, not shared
24
+ // machine-wide. Before this, one interactive sign-in (say, from Claude Code) silently
25
+ // authenticated every other local MCP host that later spawned this same bridge (e.g.
26
+ // Cursor) — no re-consent, no visibility, no way to revoke one without the other. Root
27
+ // cause and full context: spec item AUTH-51. Client identity comes from the `clientInfo.name`
28
+ // an MCP host sends in its own `initialize` request — the only client-identifying signal
29
+ // the stdio transport offers. This is NOT a security boundary (any local process can
30
+ // fabricate that name, or read these cache files directly — same-account processes always
31
+ // can) — see AUTH-51's explicit non-goal. Its job is stopping *accidental* sharing between
32
+ // distinct, legitimate MCP hosts, and making any reuse of a cached session visible via an OS
33
+ // notification rather than silent.
34
+ //
23
35
  // stdout is reserved for the JSON-RPC protocol channel; all logging goes to stderr.
24
36
 
25
37
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -48,15 +60,16 @@ const ROAMER_MCP_URL = process.env.ROAMER_MCP_URL ?? "https://roamer-mcp.mathism
48
60
  const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
49
61
 
50
62
  const CACHE_DIR = join(homedir(), ".mcp-auth-device");
51
- const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
52
- const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
53
63
 
54
- // AUTH-14: the loopback flow's own independent cachenever assumed interchangeable with
55
- // the device-code cache above, matching this file's existing precedent of not trusting a
56
- // differently-obtained cache just because it happens to share field names (see
57
- // CACHE_VERSION's own comment on the mcp-remote-era predecessor of that exact mistake).
58
- const LOOPBACK_TOKENS_FILE = join(CACHE_DIR, "roamer_loopback_tokens.json");
59
- const LOOPBACK_CLIENT_FILE = join(CACHE_DIR, "roamer_loopback_client.json");
64
+ // AUTH-51: every on-disk cache is namespaced per client slug a null slug (the connecting
65
+ // host sent no usable clientInfo.name) means "never persist": the caller gets a real
66
+ // interactive sign-in on every single invocation instead of falling into any shared
67
+ // catch-all bucket, which would just recreate the bug this item fixes for a smaller
68
+ // population of clients.
69
+ function cacheFilePath(baseName, clientSlug) {
70
+ if (!clientSlug) return null;
71
+ return join(CACHE_DIR, `${baseName}__${clientSlug}.json`);
72
+ }
60
73
 
61
74
  // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
62
75
  // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
@@ -77,6 +90,28 @@ function isAuthError(err) {
77
90
  return /invalid_token|unauthorized|\b401\b/i.test(err?.message ?? "");
78
91
  }
79
92
 
93
+ // AUTH-51: the only client-identifying signal MCP's stdio transport offers — the
94
+ // `clientInfo.name` an MCP host sends in its own `initialize` request, which by protocol is
95
+ // always the very first message a host sends. Deliberately tolerant: any parse failure or
96
+ // missing/blank name just means "no usable identity", never a thrown error — a
97
+ // non-compliant host must degrade gracefully (an uncached, always-fresh sign-in), not crash
98
+ // the bridge.
99
+ function clientSlugFromInitializeLine(line) {
100
+ try {
101
+ const message = JSON.parse(line);
102
+ const name = message?.params?.clientInfo?.name;
103
+ if (typeof name !== "string" || !name.trim()) return null;
104
+ const slug = name
105
+ .trim()
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9_-]+/g, "-")
108
+ .replace(/^-+|-+$/g, "");
109
+ return slug || null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
80
115
  // A native dialog is an OS-level surface, guaranteed visible independent of whether the MCP
81
116
  // host surfaces this process's stderr anywhere a human will see it (confirmed on the old
82
117
  // Entra-direct flow: a stderr-only first version left the user with no way to see the code).
@@ -91,9 +126,31 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
91
126
  }
92
127
  }
93
128
 
94
- async function readCachedTokens() {
129
+ // AUTH-51: fires once per process, only when the token this run ends up using was obtained
130
+ // without any interactive step this time (a cache hit or a silent background refresh) — the
131
+ // exact moment that used to be completely invisible. Deliberately NOT fired for routine
132
+ // mid-session refreshes of an already-visible, already-established session (see forwardLine
133
+ // below) — only for the initial per-process acquisition, so a long-lived connection doesn't
134
+ // spam a notification every time its token happens to roll over.
135
+ async function notifySessionReused(clientSlug) {
136
+ const label = clientSlug ?? "an unidentified client";
137
+ const message = `Reused an existing Roamer MCP session for ${label}.`;
95
138
  try {
96
- const tokens = JSON.parse(await readFile(CACHE_FILE, "utf8"));
139
+ await execFileAsync("osascript", [
140
+ "-e",
141
+ `display notification "${message.replace(/"/g, '\\"')}" with title "Roamer MCP"`,
142
+ ]);
143
+ } catch {
144
+ // Best-effort, macOS-only — the stderr log line below is the fallback for every other OS.
145
+ }
146
+ log(message);
147
+ }
148
+
149
+ async function readCachedTokens(clientSlug) {
150
+ const path = cacheFilePath("roamer_tokens", clientSlug);
151
+ if (!path) return null;
152
+ try {
153
+ const tokens = JSON.parse(await readFile(path, "utf8"));
97
154
  // A cache from an incompatible prior format must never be trusted just because it
98
155
  // happens to have the right field names — see CACHE_VERSION's comment.
99
156
  if (tokens?.cacheVersion !== CACHE_VERSION) return null;
@@ -103,14 +160,18 @@ async function readCachedTokens() {
103
160
  }
104
161
  }
105
162
 
106
- async function writeCachedTokens(tokens) {
163
+ async function writeCachedTokens(clientSlug, tokens) {
164
+ const path = cacheFilePath("roamer_tokens", clientSlug);
165
+ if (!path) return; // AUTH-51: no usable client identity — never persisted.
107
166
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
108
167
  const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
109
- await writeFile(CACHE_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
168
+ await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
110
169
  }
111
170
 
112
- function clearCachedTokens() {
113
- return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
171
+ function clearCachedTokens(clientSlug) {
172
+ const path = cacheFilePath("roamer_tokens", clientSlug);
173
+ if (!path) return Promise.resolve();
174
+ return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
114
175
  }
115
176
 
116
177
  // AUTH-14/AUTH-38: loopback-flow caches, stamped with the authorization server URL they were
@@ -120,9 +181,11 @@ function clearCachedTokens() {
120
181
  // discovery documents from Cloudflare Access Managed OAuth to RoamerMcp's own AS, and a client_id
121
182
  // or token cached from before that change would otherwise be silently carried forward and
122
183
  // presented to a completely different issuer with no invalidation at all.
123
- async function readLoopbackTokens(issuerUrl) {
184
+ async function readLoopbackTokens(clientSlug, issuerUrl) {
185
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
186
+ if (!path) return null;
124
187
  try {
125
- const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
188
+ const tokens = JSON.parse(await readFile(path, "utf8"));
126
189
  if (tokens?.issuerUrl !== issuerUrl) return null;
127
190
  return tokens?.access_token ? tokens : null;
128
191
  } catch {
@@ -130,22 +193,28 @@ async function readLoopbackTokens(issuerUrl) {
130
193
  }
131
194
  }
132
195
 
133
- async function writeLoopbackTokens(tokens, issuerUrl) {
196
+ async function writeLoopbackTokens(clientSlug, tokens, issuerUrl) {
197
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
198
+ if (!path) return; // AUTH-51: no usable client identity — never persisted.
134
199
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
135
200
  const stamped = { ...tokens, issuerUrl };
136
- await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
201
+ await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
137
202
  }
138
203
 
139
- function clearLoopbackTokens() {
140
- return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
204
+ function clearLoopbackTokens(clientSlug) {
205
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
206
+ if (!path) return Promise.resolve();
207
+ return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
141
208
  }
142
209
 
143
210
  // DCR'd client registration is reused across runs — the authorization server's
144
211
  // registration_endpoint has no reason to be hit on every single sign-in, only the first one (or
145
212
  // after a reset, or after an issuer change per this function's own issuerUrl check above).
146
- async function readLoopbackClientInfo(issuerUrl) {
213
+ async function readLoopbackClientInfo(clientSlug, issuerUrl) {
214
+ const path = cacheFilePath("roamer_loopback_client", clientSlug);
215
+ if (!path) return null;
147
216
  try {
148
- const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
217
+ const info = JSON.parse(await readFile(path, "utf8"));
149
218
  if (info?.issuerUrl !== issuerUrl) return null;
150
219
  return info?.client_id ? info : null;
151
220
  } catch {
@@ -153,10 +222,12 @@ async function readLoopbackClientInfo(issuerUrl) {
153
222
  }
154
223
  }
155
224
 
156
- async function writeLoopbackClientInfo(info, issuerUrl) {
225
+ async function writeLoopbackClientInfo(clientSlug, info, issuerUrl) {
226
+ const path = cacheFilePath("roamer_loopback_client", clientSlug);
227
+ if (!path) return; // AUTH-51: no usable client identity — never persisted.
157
228
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
158
229
  const stamped = { ...info, issuerUrl };
159
- await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
230
+ await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
160
231
  }
161
232
 
162
233
  // AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
@@ -165,7 +236,7 @@ async function writeLoopbackClientInfo(info, issuerUrl) {
165
236
  // non-macOS platform (no Launch Services plist to query, so this always reports "unknown"
166
237
  // there — correctly falling through to the loopback path), takes the lighter direct
167
238
  // redirect. ROAMER_MCP_AUTH_FLOW overrides detection entirely — used by this file's own
168
- // test suite to pin a deterministic path regardless of the CI machine's real OS/browser.
239
+ // test suite to pin a deterministic path regardless of the CI runner's real OS/default browser.
169
240
  async function detectDefaultBrowser() {
170
241
  const override = process.env.ROAMER_MCP_AUTH_FLOW;
171
242
  if (override === "device-code") return "com.apple.safari";
@@ -193,9 +264,11 @@ async function detectDefaultBrowser() {
193
264
  // user_code — the user would see a different code every single retry, forever, with no
194
265
  // path to ever actually finish signing in. Confirmed live 2026-08-21. Persisting the
195
266
  // pending flow means a respawned process resumes polling the SAME still-valid code instead.
196
- async function readPendingFlow() {
267
+ async function readPendingFlow(clientSlug) {
268
+ const path = cacheFilePath("roamer_pending", clientSlug);
269
+ if (!path) return null;
197
270
  try {
198
- const pending = JSON.parse(await readFile(PENDING_FILE, "utf8"));
271
+ const pending = JSON.parse(await readFile(path, "utf8"));
199
272
  if (!pending?.device_code || Date.now() > pending.expiresAt) return null;
200
273
  return pending;
201
274
  } catch {
@@ -203,15 +276,19 @@ async function readPendingFlow() {
203
276
  }
204
277
  }
205
278
 
206
- async function writePendingFlow(device) {
279
+ async function writePendingFlow(clientSlug, device) {
280
+ const path = cacheFilePath("roamer_pending", clientSlug);
281
+ if (!path) return device; // AUTH-51: no usable client identity — never persisted.
207
282
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
208
283
  const pending = { ...device, expiresAt: Date.now() + device.expires_in * 1000 };
209
- await writeFile(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
284
+ await writeFile(path, JSON.stringify(pending, null, 2), { mode: 0o600 });
210
285
  return pending;
211
286
  }
212
287
 
213
- function clearPendingFlow() {
214
- return writeFile(PENDING_FILE, "{}", { mode: 0o600 }).catch(() => {});
288
+ function clearPendingFlow(clientSlug) {
289
+ const path = cacheFilePath("roamer_pending", clientSlug);
290
+ if (!path) return Promise.resolve();
291
+ return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
215
292
  }
216
293
 
217
294
  function expiresSoon(tokens) {
@@ -271,21 +348,28 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
271
348
  // their own competing flow — worst case that means multiple device-code dialogs popping up
272
349
  // at once, or two refreshes racing on a rotating refresh_token where the loser's retry then
273
350
  // forces an unnecessary full sign-in. All concurrent callers await the same in-flight op.
351
+ // AUTH-51: still a single (not per-client) in-flight guard — a given process only ever
352
+ // resolves one clientSlug for its whole lifetime, decided once at startup, so there's never
353
+ // more than one client's flow in flight within a single process anyway.
274
354
  let inFlightTokens = null;
275
355
 
276
- function getValidTokens(forceRefresh = false) {
356
+ function getValidTokens(clientSlug, forceRefresh = false) {
277
357
  if (inFlightTokens) return inFlightTokens;
278
- inFlightTokens = doGetValidTokens(forceRefresh).finally(() => {
358
+ inFlightTokens = doGetValidTokens(clientSlug, forceRefresh).finally(() => {
279
359
  inFlightTokens = null;
280
360
  });
281
361
  return inFlightTokens;
282
362
  }
283
363
 
284
- async function doGetValidTokens(forceRefresh) {
285
- let tokens = forceRefresh ? null : await readCachedTokens();
364
+ // AUTH-51: returns { tokens, reusedSilently } — reusedSilently is true only when no
365
+ // interactive step ran this call (a valid cache hit or a silent refresh-token refresh),
366
+ // which is exactly the case that used to be invisible and is now what triggers
367
+ // notifySessionReused in main().
368
+ async function doGetValidTokens(clientSlug, forceRefresh) {
369
+ let tokens = forceRefresh ? null : await readCachedTokens(clientSlug);
286
370
 
287
371
  if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
288
- return tokens;
372
+ return { tokens, reusedSilently: true };
289
373
  }
290
374
 
291
375
  if (!forceRefresh && tokens?.refresh_token) {
@@ -293,23 +377,23 @@ async function doGetValidTokens(forceRefresh) {
293
377
  log("Refreshing cached token...");
294
378
  const fresh = await refreshTokens(tokens.refresh_token);
295
379
  tokens = { ...fresh, obtained_at: Date.now() };
296
- await writeCachedTokens(tokens);
297
- return tokens;
380
+ await writeCachedTokens(clientSlug, tokens);
381
+ return { tokens, reusedSilently: true };
298
382
  } catch (err) {
299
383
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
300
384
  }
301
385
  }
302
386
 
303
- if (forceRefresh) await clearCachedTokens();
387
+ if (forceRefresh) await clearCachedTokens(clientSlug);
304
388
 
305
389
  // Resume an already-in-progress flow (from a process this host killed and respawned)
306
390
  // instead of minting a new device_code the user would have to start over for.
307
- let device = await readPendingFlow();
391
+ let device = await readPendingFlow(clientSlug);
308
392
  let resuming = Boolean(device);
309
393
  if (!resuming) {
310
394
  log("Starting sign-in...");
311
395
  device = await startDeviceFlow();
312
- await writePendingFlow(device);
396
+ await writePendingFlow(clientSlug, device);
313
397
  } else {
314
398
  log("Resuming an already-in-progress sign-in (a prior process was restarted before it finished)...");
315
399
  }
@@ -339,15 +423,15 @@ async function doGetValidTokens(forceRefresh) {
339
423
  try {
340
424
  const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
341
425
  tokens = { ...fresh, obtained_at: Date.now() };
342
- await writeCachedTokens(tokens);
343
- await clearPendingFlow();
426
+ await writeCachedTokens(clientSlug, tokens);
427
+ await clearPendingFlow(clientSlug);
344
428
  log("Sign-in complete.");
345
- return tokens;
429
+ return { tokens, reusedSilently: false };
346
430
  } catch (err) {
347
431
  // A hard failure (expired/denied, not just this process being killed) means the pending
348
432
  // code is genuinely dead — clear it so the next attempt starts a real fresh one instead
349
433
  // of retrying a code that will never succeed.
350
- await clearPendingFlow();
434
+ await clearPendingFlow(clientSlug);
351
435
  throw err;
352
436
  }
353
437
  }
@@ -358,6 +442,9 @@ async function doGetValidTokens(forceRefresh) {
358
442
  // stable across runs. A fixed port sidesteps re-registering on every single interactive
359
443
  // sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
360
444
  // project's original AUTH-11/AUTH-14 mcp-remote-based flow already lived with for months.
445
+ // AUTH-51: this is a pre-existing, unrelated limitation — two different MCP hosts both
446
+ // needing a fresh interactive loopback sign-in at literally the same moment would still
447
+ // race on this one port, same as two runs of the same client already could before this fix.
361
448
  const LOOPBACK_PORT = Number(process.env.ROAMER_MCP_OAUTH_PORT ?? 38271);
362
449
  const LOOPBACK_REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
363
450
 
@@ -415,56 +502,61 @@ async function discoverLoopbackServerInfo() {
415
502
  // of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
416
503
  let inFlightLoopbackTokens = null;
417
504
 
418
- function getValidTokensLoopback(forceRefresh = false) {
505
+ function getValidTokensLoopback(clientSlug, forceRefresh = false) {
419
506
  if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
420
- inFlightLoopbackTokens = doGetValidTokensLoopback(forceRefresh).finally(() => {
507
+ inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
421
508
  inFlightLoopbackTokens = null;
422
509
  });
423
510
  return inFlightLoopbackTokens;
424
511
  }
425
512
 
426
- async function doGetValidTokensLoopback(forceRefresh) {
513
+ // AUTH-51: returns { tokens, reusedSilently } — see doGetValidTokens's comment above, same
514
+ // contract for the loopback mechanism.
515
+ async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
427
516
  // AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
428
517
  // cached-but-not-yet-expired token from a prior authorization server would otherwise be
429
518
  // returned early below without ever learning the issuer changed underneath it.
430
519
  const serverInfo = await discoverLoopbackServerInfo();
431
520
  const issuerUrl = serverInfo.authorizationServerUrl.toString();
432
521
 
433
- let tokens = forceRefresh ? null : await readLoopbackTokens(issuerUrl);
522
+ let tokens = forceRefresh ? null : await readLoopbackTokens(clientSlug, issuerUrl);
434
523
 
435
524
  if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
436
- return tokens;
525
+ return { tokens, reusedSilently: true };
437
526
  }
438
527
 
439
- let clientInformation = await readLoopbackClientInfo(issuerUrl);
528
+ let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
440
529
 
441
530
  if (!forceRefresh && tokens?.refresh_token && clientInformation) {
442
531
  try {
443
532
  log("Refreshing cached token...");
444
533
  const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
445
534
  tokens = { ...fresh, obtained_at: Date.now() };
446
- await writeLoopbackTokens(tokens, issuerUrl);
447
- return tokens;
535
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
536
+ return { tokens, reusedSilently: true };
448
537
  } catch (err) {
449
538
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
450
539
  }
451
540
  }
452
541
 
453
- if (forceRefresh) await clearLoopbackTokens();
542
+ if (forceRefresh) await clearLoopbackTokens(clientSlug);
454
543
 
455
544
  if (!clientInformation) {
456
545
  log("Registering as a new OAuth client...");
546
+ // AUTH-51: the registered client_name carries the connecting client's own identity when
547
+ // known, so a future server-side session list/audit view could actually tell clients
548
+ // apart instead of seeing the same generic name for every stdio-bridge user.
457
549
  clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
458
550
  metadata: serverInfo.authorizationServerMetadata,
459
551
  clientMetadata: {
460
- client_name: "Roamer MCP (stdio bridge)",
552
+ client_name: clientSlug ? `Roamer MCP (stdio bridge — ${clientSlug})` : "Roamer MCP (stdio bridge)",
461
553
  redirect_uris: [LOOPBACK_REDIRECT_URI],
462
554
  grant_types: ["authorization_code", "refresh_token"],
463
555
  response_types: ["code"],
464
556
  token_endpoint_auth_method: "none",
465
557
  },
466
558
  });
467
- await writeLoopbackClientInfo(clientInformation, issuerUrl);
559
+ await writeLoopbackClientInfo(clientSlug, clientInformation, issuerUrl);
468
560
  }
469
561
 
470
562
  log("Starting sign-in...");
@@ -517,13 +609,16 @@ async function doGetValidTokensLoopback(forceRefresh) {
517
609
  redirectUri: LOOPBACK_REDIRECT_URI,
518
610
  });
519
611
  tokens = { ...fresh, obtained_at: Date.now() };
520
- await writeLoopbackTokens(tokens, issuerUrl);
612
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
521
613
  log("Sign-in complete.");
522
- return tokens;
614
+ return { tokens, reusedSilently: false };
523
615
  }
524
616
 
525
617
  // AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
526
618
  // queued while sign-in was still in progress, with identical forwarding/retry behavior.
619
+ // AUTH-51: unchanged — takes a plain getFreshTokens(forceRefresh) => Promise<tokens>
620
+ // function; main() adapts the new { tokens, reusedSilently }-returning, clientSlug-aware
621
+ // functions above into that shape rather than changing this function's own contract.
527
622
  async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens) {
528
623
  try {
529
624
  let tokens = getTokens();
@@ -577,6 +672,15 @@ async function main() {
577
672
  let ready = false;
578
673
  const pendingLines = [];
579
674
 
675
+ // AUTH-51: which client is asking, resolved once from the first stdin line (the MCP
676
+ // `initialize` request, always message #1 by protocol) before any credential is touched.
677
+ let clientSlug = null;
678
+ let slugResolved = false;
679
+ let resolveFirstLine;
680
+ const firstLine = new Promise((resolve) => {
681
+ resolveFirstLine = resolve;
682
+ });
683
+
580
684
  const getTokens = () => tokens;
581
685
  const setTokens = (fresh) => {
582
686
  tokens = fresh;
@@ -586,8 +690,9 @@ async function main() {
586
690
  // only consumer) never runs before `ready` flips true, which itself never happens before
587
691
  // these are set, so deferring the assignment past the listener attach is safe and keeps
588
692
  // AUTH-28's guarantee (listener attaches before any await) intact.
589
- let getFreshTokens;
590
- let clearFreshTokens;
693
+ let getFreshTokens; // (clientSlug, forceRefresh) => Promise<{ tokens, reusedSilently }>
694
+ let clearFreshTokens; // () => Promise<void>
695
+ let freshTokensForForward; // (forceRefresh) => Promise<tokens> — forwardLine's plain-tokens contract
591
696
 
592
697
  let buffer = "";
593
698
  process.stdin.setEncoding("utf8");
@@ -598,8 +703,13 @@ async function main() {
598
703
  const line = buffer.slice(0, newlineIndex);
599
704
  buffer = buffer.slice(newlineIndex + 1);
600
705
  if (!line.trim()) continue;
706
+ if (!slugResolved) {
707
+ slugResolved = true;
708
+ clientSlug = clientSlugFromInitializeLine(line);
709
+ resolveFirstLine();
710
+ }
601
711
  if (ready) {
602
- forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
712
+ forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
603
713
  } else {
604
714
  pendingLines.push(line);
605
715
  }
@@ -614,16 +724,29 @@ async function main() {
614
724
 
615
725
  log("Local STDIO proxy running. Press Ctrl+C to exit.");
616
726
 
727
+ // AUTH-51: wait for that first line before authenticating, so the credential this process
728
+ // obtains is scoped to the right client. Safe alongside AUTH-28's no-silence guarantee —
729
+ // the listener above is already attached and queuing by this point, and a well-behaved
730
+ // host sends `initialize` immediately on spawn with nothing required from this process
731
+ // first, so this wait adds no meaningful latency and never reintroduces the
732
+ // respond-before-timeout gap AUTH-28 fixed.
733
+ await firstLine;
734
+ log(clientSlug ? `Client identified as "${clientSlug}".` : "No usable client identity sent — this sign-in will not be cached for reuse.");
735
+
617
736
  // AUTH-14: decided once per process — detectDefaultBrowser() shells out to Launch
618
737
  // Services, no need to re-check mid-session. Safari-default machines keep using the
619
738
  // existing device-code mechanism (AUTH-25); everything else uses the lighter loopback
620
739
  // redirect, including refreshes and reactive re-auth below.
621
740
  const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
622
741
  getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
623
- clearFreshTokens = isSafari ? clearCachedTokens : clearLoopbackTokens;
742
+ clearFreshTokens = () => (isSafari ? clearCachedTokens(clientSlug) : clearLoopbackTokens(clientSlug));
743
+ freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
624
744
 
745
+ let reusedSilently = false;
625
746
  try {
626
- tokens = await getFreshTokens();
747
+ const result = await getFreshTokens(clientSlug);
748
+ tokens = result.tokens;
749
+ reusedSilently = result.reusedSilently;
627
750
  } catch (err) {
628
751
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
629
752
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -633,6 +756,10 @@ async function main() {
633
756
  process.exit(1);
634
757
  }
635
758
 
759
+ // AUTH-51: the moment that used to be completely silent — a process starting up and
760
+ // immediately using a credential it never interactively obtained this run.
761
+ if (reusedSilently) await notifySessionReused(clientSlug);
762
+
636
763
  transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
637
764
  requestInit: {
638
765
  get headers() {
@@ -660,7 +787,7 @@ async function main() {
660
787
 
661
788
  ready = true;
662
789
  for (const line of pendingLines) {
663
- forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
790
+ forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
664
791
  }
665
792
  }
666
793
 
@@ -681,6 +808,12 @@ export {
681
808
  respondWithSignInError,
682
809
  forwardLine,
683
810
  detectDefaultBrowser,
811
+ clientSlugFromInitializeLine,
812
+ readCachedTokens,
813
+ writeCachedTokens,
814
+ clearCachedTokens,
815
+ readPendingFlow,
816
+ writePendingFlow,
684
817
  readLoopbackTokens,
685
818
  writeLoopbackTokens,
686
819
  readLoopbackClientInfo,