@mathismeadows/roamer-device-auth 1.4.2 → 1.5.1

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,10 +1,10 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "private": false,
5
5
  "mcpName": "com.mathismeadows/roamer-mcp",
6
6
  "type": "module",
7
- "description": "AUTH-14/AUTH-25/AUTH-38: stdio<->HTTP MCP proxy for Roamer MCP with two auth mechanisms, chosen per-machine by default-browser detection. Safari-default machines use a server-mediated device authorization flow (AUTH-25) since Safari's HTTPS-Only Mode blocks a loopback redirect; every other machine uses a standard direct loopback redirect instead. Both authenticate against RoamerMcp's own OAuth authorization server (AUTH-38).",
7
+ "description": "AUTH-14/AUTH-25/AUTH-38: stdio<->HTTP MCP proxy for Roamer MCP with two auth mechanisms, chosen per-machine by default-browser detection. Safari-default machines use a server-mediated device authorization flow (AUTH-25) since Safari's HTTPS-Only Mode blocks a loopback redirect; every other machine uses a standard direct loopback redirect instead. Both authenticate against RoamerMcp's own OAuth authorization server (AUTH-38). AUTH-54/55/57/58: sessions are cached per (client, identity) pair, with explicit login/logout/status subcommands to switch or inspect which identity a client is using.",
8
8
  "bin": {
9
9
  "roamer-device-auth": "./roamer-device-auth.mjs"
10
10
  },
@@ -43,7 +43,7 @@ import {
43
43
  exchangeAuthorization,
44
44
  refreshAuthorization,
45
45
  } from "@modelcontextprotocol/sdk/client/auth.js";
46
- import { readFile, writeFile, mkdir } from "node:fs/promises";
46
+ import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
47
47
  import { realpathSync } from "node:fs";
48
48
  import { homedir } from "node:os";
49
49
  import { join } from "node:path";
@@ -66,9 +66,141 @@ const CACHE_DIR = join(homedir(), ".mcp-auth-device");
66
66
  // interactive sign-in on every single invocation instead of falling into any shared
67
67
  // catch-all bucket, which would just recreate the bug this item fixes for a smaller
68
68
  // population of clients.
69
- function cacheFilePath(baseName, clientSlug) {
69
+ //
70
+ // AUTH-54: an optional third segment, identityKey, namespaces the token caches further by
71
+ // WHICH PERSON authenticated, not just which app is connecting — AUTH-51 alone still let a
72
+ // second identity on the same client silently reuse or shadow the first one's cache, since
73
+ // identity wasn't part of the key at all. Only the actual token caches take an identityKey
74
+ // (see call sites below); roamer_loopback_client (DCR registration) and roamer_pending
75
+ // (in-progress device-code flow) are deliberately left clientSlug-only — a registered OAuth
76
+ // client and an in-flight sign-in aren't tied to any one person yet, and folding an identity
77
+ // segment into them would just be dead complexity. `__` is reserved as this function's own
78
+ // delimiter: identityKeyFromTokens below never emits one, so splitting on it is unambiguous.
79
+ function cacheFilePath(baseName, clientSlug, identityKey = null) {
70
80
  if (!clientSlug) return null;
71
- return join(CACHE_DIR, `${baseName}__${clientSlug}.json`);
81
+ const suffix = identityKey ? `__${identityKey}` : "";
82
+ return join(CACHE_DIR, `${baseName}__${clientSlug}${suffix}.json`);
83
+ }
84
+
85
+ // AUTH-54: derives a stable, filesystem-safe identity key from an access token's own claims.
86
+ // Prefers oid deliberately (over identityLabelFromTokens' email-first choice below) — oid is
87
+ // durable and never changes for a given person, where email/preferred_username can (a verified
88
+ // contact email update, a UPN change). Keying the cache FILE by something that can change would
89
+ // silently orphan an existing cache entry and grow a spurious "new identity" next to it, which
90
+ // is exactly the confusion this item exists to prevent. No signature verification here — this
91
+ // is purely a local cache-partitioning key, not a trust decision; the server has already
92
+ // validated the token by the time this process holds it.
93
+ function identityKeyFromTokens(tokens) {
94
+ const raw = decodeTokenPayload(tokens)?.oid ?? identityLabelFromTokens(tokens);
95
+ if (!raw) return null;
96
+ return String(raw)
97
+ .trim()
98
+ .toLowerCase()
99
+ // Deliberately excludes "_" (unlike clientSlugFromInitializeLine's slug sanitizer) so "__"
100
+ // stays a delimiter cacheFilePath can split on unambiguously.
101
+ .replace(/[^a-z0-9.@-]+/g, "-")
102
+ .replace(/^-+|-+$/g, "") || null;
103
+ }
104
+
105
+ // AUTH-54/56: the human-readable form (email/preferred_username first, falling back to the raw
106
+ // oid) used for the "status" listing and the AUTH-56 stderr line — deliberately independent of
107
+ // identityKeyFromTokens' own oid-first priority above: a key optimizes for stability, a label
108
+ // optimizes for being recognizable to the person reading it.
109
+ function identityLabelFromTokens(tokens) {
110
+ const payload = decodeTokenPayload(tokens);
111
+ return payload?.email ?? payload?.preferred_username ?? payload?.oid ?? null;
112
+ }
113
+
114
+ function decodeTokenPayload(tokens) {
115
+ try {
116
+ return JSON.parse(Buffer.from(tokens.access_token.split(".")[1], "base64url").toString("utf8"));
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ // AUTH-54: which identity a client slug's NORMAL (non-login/logout/status) runs should use.
123
+ // This is the piece that makes identity-aware caching invisible to the common single-identity
124
+ // case — the first-ever sign-in for a client writes both the token cache AND this pointer, so
125
+ // every later ordinary run finds it automatically with no config change. It only changes when
126
+ // an explicit `login` establishes a new one, or `logout` clears it back to "none" (AUTH-57).
127
+ async function readActiveIdentity(clientSlug) {
128
+ const path = cacheFilePath("roamer_active", clientSlug);
129
+ if (!path) return null;
130
+ try {
131
+ const active = JSON.parse(await readFile(path, "utf8"));
132
+ return active?.identityKey ? active : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ async function writeActiveIdentity(clientSlug, { identityKey, label }) {
139
+ const path = cacheFilePath("roamer_active", clientSlug);
140
+ if (!path) return;
141
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
142
+ await writeFile(path, JSON.stringify({ identityKey, label }, null, 2), { mode: 0o600 });
143
+ }
144
+
145
+ function clearActiveIdentity(clientSlug) {
146
+ const path = cacheFilePath("roamer_active", clientSlug);
147
+ if (!path) return Promise.resolve();
148
+ return unlink(path).catch(() => {});
149
+ }
150
+
151
+ // AUTH-58: lists every identity currently cached for a client slug, across both auth
152
+ // mechanisms (a machine's default browser — and therefore which mechanism is in play — can
153
+ // change over time; a person could have identities cached under either). Reads each file's own
154
+ // stamped label rather than keeping a separate index, so this can never drift from what's
155
+ // actually on disk — the same self-describing-file approach CACHE_VERSION already uses.
156
+ async function listCachedIdentities(clientSlug) {
157
+ const active = await readActiveIdentity(clientSlug);
158
+ const prefixes = [`roamer_tokens__${clientSlug}__`, `roamer_loopback_tokens__${clientSlug}__`];
159
+ const seen = new Map(); // identityKey -> label, deduped across the two mechanisms
160
+ let entries;
161
+ try {
162
+ entries = await readdir(CACHE_DIR);
163
+ } catch {
164
+ entries = [];
165
+ }
166
+ for (const entry of entries) {
167
+ const prefix = prefixes.find((p) => entry.startsWith(p));
168
+ if (!prefix || !entry.endsWith(".json")) continue;
169
+ const identityKey = entry.slice(prefix.length, -".json".length);
170
+ if (!identityKey || seen.has(identityKey)) continue;
171
+ try {
172
+ const contents = JSON.parse(await readFile(join(CACHE_DIR, entry), "utf8"));
173
+ seen.set(identityKey, contents?.label ?? identityKey);
174
+ } catch {
175
+ seen.set(identityKey, identityKey);
176
+ }
177
+ }
178
+ return [...seen.entries()].map(([identityKey, label]) => ({
179
+ identityKey,
180
+ label,
181
+ active: identityKey === active?.identityKey,
182
+ }));
183
+ }
184
+
185
+ // AUTH-55/57/58: login/logout/status run standalone, with no MCP `initialize` handshake to
186
+ // derive a client slug from the way normal bridge operation does — so unlike every other
187
+ // function in this file, they need to discover what slugs even exist on disk, to guide someone
188
+ // who omitted --client (or got it wrong) rather than just failing opaquely.
189
+ async function listKnownClientSlugs() {
190
+ let entries;
191
+ try {
192
+ entries = await readdir(CACHE_DIR);
193
+ } catch {
194
+ return [];
195
+ }
196
+ const slugs = new Set();
197
+ for (const entry of entries) {
198
+ // Safe to split on "__" without ambiguity: neither a client slug (clientSlugFromInitializeLine)
199
+ // nor an identity key (identityKeyFromTokens) can contain an underscore post-sanitization.
200
+ const match = entry.match(/^roamer_(?:tokens|loopback_tokens)__([^_]+)__/);
201
+ if (match) slugs.add(match[1]);
202
+ }
203
+ return [...slugs].sort();
72
204
  }
73
205
 
74
206
  // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
@@ -96,6 +228,13 @@ function isAuthError(err) {
96
228
  // missing/blank name just means "no usable identity", never a thrown error — a
97
229
  // non-compliant host must degrade gracefully (an uncached, always-fresh sign-in), not crash
98
230
  // the bridge.
231
+ //
232
+ // AUTH-54: "_" was dropped from the allowed character set (it previously joined "-" as
233
+ // filename-safe) so a client slug can never itself contain "__" — that sequence is now
234
+ // cacheFilePath's own reserved delimiter between the client-slug and identity-key segments of
235
+ // a filename, and listKnownClientSlugs/listCachedIdentities parse filenames back apart on
236
+ // exactly that assumption. No real MCP client name has ever produced an underscore here in
237
+ // practice; this only changes behavior for a hypothetical clientInfo.name containing one.
99
238
  function clientSlugFromInitializeLine(line) {
100
239
  try {
101
240
  const message = JSON.parse(line);
@@ -104,7 +243,7 @@ function clientSlugFromInitializeLine(line) {
104
243
  const slug = name
105
244
  .trim()
106
245
  .toLowerCase()
107
- .replace(/[^a-z0-9_-]+/g, "-")
246
+ .replace(/[^a-z0-9-]+/g, "-")
108
247
  .replace(/^-+|-+$/g, "");
109
248
  return slug || null;
110
249
  } catch {
@@ -132,9 +271,14 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
132
271
  // mid-session refreshes of an already-visible, already-established session (see forwardLine
133
272
  // below) — only for the initial per-process acquisition, so a long-lived connection doesn't
134
273
  // 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}.`;
274
+ // AUTH-54: now includes the identity label alongside the client slug — AUTH-51's original
275
+ // notification could only ever say "which app", not "reused as whom", which is exactly the
276
+ // distinction someone needs in order to notice a stale identity is still active.
277
+ async function notifySessionReused(clientSlug, identityLabel) {
278
+ const clientLabel = clientSlug ?? "an unidentified client";
279
+ const message = identityLabel
280
+ ? `Reused an existing Roamer MCP session for ${clientLabel} (${identityLabel}).`
281
+ : `Reused an existing Roamer MCP session for ${clientLabel}.`;
138
282
  try {
139
283
  await execFileAsync("osascript", [
140
284
  "-e",
@@ -146,8 +290,12 @@ async function notifySessionReused(clientSlug) {
146
290
  log(message);
147
291
  }
148
292
 
149
- async function readCachedTokens(clientSlug) {
150
- const path = cacheFilePath("roamer_tokens", clientSlug);
293
+ // AUTH-54: identityKey defaults to null (the pre-AUTH-54 file shape) rather than being
294
+ // required, so existing callers/tests that only ever knew about clientSlug keep working
295
+ // unchanged — production call sites in doGetValidTokens below always pass a real one once an
296
+ // identity is known.
297
+ async function readCachedTokens(clientSlug, identityKey = null) {
298
+ const path = cacheFilePath("roamer_tokens", clientSlug, identityKey);
151
299
  if (!path) return null;
152
300
  try {
153
301
  const tokens = JSON.parse(await readFile(path, "utf8"));
@@ -160,16 +308,19 @@ async function readCachedTokens(clientSlug) {
160
308
  }
161
309
  }
162
310
 
163
- async function writeCachedTokens(clientSlug, tokens) {
164
- const path = cacheFilePath("roamer_tokens", clientSlug);
311
+ // AUTH-54: stamps `label` alongside the pre-existing `cacheVersion` so listCachedIdentities
312
+ // (AUTH-58) can read a human-readable identity straight off the file, without re-decoding the
313
+ // access token or keeping a separate index that could drift from what's actually on disk.
314
+ async function writeCachedTokens(clientSlug, tokens, identityKey = null) {
315
+ const path = cacheFilePath("roamer_tokens", clientSlug, identityKey);
165
316
  if (!path) return; // AUTH-51: no usable client identity — never persisted.
166
317
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
167
- const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
318
+ const stamped = { ...tokens, cacheVersion: CACHE_VERSION, label: identityLabelFromTokens(tokens) };
168
319
  await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
169
320
  }
170
321
 
171
- function clearCachedTokens(clientSlug) {
172
- const path = cacheFilePath("roamer_tokens", clientSlug);
322
+ function clearCachedTokens(clientSlug, identityKey = null) {
323
+ const path = cacheFilePath("roamer_tokens", clientSlug, identityKey);
173
324
  if (!path) return Promise.resolve();
174
325
  return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
175
326
  }
@@ -181,8 +332,10 @@ function clearCachedTokens(clientSlug) {
181
332
  // discovery documents from Cloudflare Access Managed OAuth to RoamerMcp's own AS, and a client_id
182
333
  // or token cached from before that change would otherwise be silently carried forward and
183
334
  // presented to a completely different issuer with no invalidation at all.
184
- async function readLoopbackTokens(clientSlug, issuerUrl) {
185
- const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
335
+ // AUTH-54: identityKey defaults to null for the same backward-compatibility reason as
336
+ // readCachedTokens above.
337
+ async function readLoopbackTokens(clientSlug, issuerUrl, identityKey = null) {
338
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey);
186
339
  if (!path) return null;
187
340
  try {
188
341
  const tokens = JSON.parse(await readFile(path, "utf8"));
@@ -193,16 +346,16 @@ async function readLoopbackTokens(clientSlug, issuerUrl) {
193
346
  }
194
347
  }
195
348
 
196
- async function writeLoopbackTokens(clientSlug, tokens, issuerUrl) {
197
- const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
349
+ async function writeLoopbackTokens(clientSlug, tokens, issuerUrl, identityKey = null) {
350
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey);
198
351
  if (!path) return; // AUTH-51: no usable client identity — never persisted.
199
352
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
200
- const stamped = { ...tokens, issuerUrl };
353
+ const stamped = { ...tokens, issuerUrl, label: identityLabelFromTokens(tokens) };
201
354
  await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
202
355
  }
203
356
 
204
- function clearLoopbackTokens(clientSlug) {
205
- const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
357
+ function clearLoopbackTokens(clientSlug, identityKey = null) {
358
+ const path = cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey);
206
359
  if (!path) return Promise.resolve();
207
360
  return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
208
361
  }
@@ -360,38 +513,65 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
360
513
  // more than one client's flow in flight within a single process anyway.
361
514
  let inFlightTokens = null;
362
515
 
363
- function getValidTokens(clientSlug, forceRefresh = false) {
516
+ function getValidTokens(clientSlug, forceRefresh = false, forceFreshLogin = false) {
364
517
  if (inFlightTokens) return inFlightTokens;
365
- inFlightTokens = doGetValidTokens(clientSlug, forceRefresh).finally(() => {
518
+ inFlightTokens = doGetValidTokens(clientSlug, forceRefresh, forceFreshLogin).finally(() => {
366
519
  inFlightTokens = null;
367
520
  });
368
521
  return inFlightTokens;
369
522
  }
370
523
 
371
- // AUTH-51: returns { tokens, reusedSilently } — reusedSilently is true only when no
372
- // interactive step ran this call (a valid cache hit or a silent refresh-token refresh),
373
- // which is exactly the case that used to be invisible and is now what triggers
374
- // notifySessionReused in main().
375
- async function doGetValidTokens(clientSlug, forceRefresh) {
376
- let tokens = forceRefresh ? null : await readCachedTokens(clientSlug);
377
-
378
- if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
379
- return { tokens, reusedSilently: true };
524
+ // AUTH-51/54: returns { tokens, reusedSilently, identityKey, label } — reusedSilently is true
525
+ // only when no interactive step ran this call (a valid cache hit or a silent refresh-token
526
+ // refresh), which is exactly the case that used to be invisible and is now what triggers
527
+ // notifySessionReused in main(). identityKey/label are the person this run is/became — resolved
528
+ // from the active-identity pointer (AUTH-54) for a normal run, or freshly derived once a
529
+ // brand-new interactive sign-in completes.
530
+ //
531
+ // AUTH-55: forceFreshLogin (distinct from the pre-existing forceRefresh) is the `login`
532
+ // command's own entry point — unlike forceRefresh, which still operates against whichever
533
+ // identity is CURRENTLY active (the existing AUTH-51/reactive-401-invalidation behavior,
534
+ // unchanged), forceFreshLogin skips resolving the active identity at all, always runs a fresh
535
+ // interactive sign-in, and then makes WHOEVER signs in the new active identity — without
536
+ // deleting any other identity's own cache entry.
537
+ async function doGetValidTokens(clientSlug, forceRefresh, forceFreshLogin = false) {
538
+ const active = forceFreshLogin ? null : await readActiveIdentity(clientSlug);
539
+ const identityKey = active?.identityKey ?? null;
540
+
541
+ // BUG (found live, same session, immediately post-1.5.0-publish): identityKey being null
542
+ // here is ambiguous between "no active identity has ever been established" (must never read
543
+ // any file — force a fresh sign-in) and "explicitly asked for the identity-less cache" (only
544
+ // ever true from a 2-arg test call). cacheFilePath maps a null identityKey to the exact same
545
+ // filename AUTH-54 shipped alongside — the pre-AUTH-54 file every already-authenticated
546
+ // client already had on disk. Without this guard, a machine upgrading straight from a
547
+ // pre-AUTH-54 version silently keeps reusing that old file forever (a real "reused existing
548
+ // creds" notification a user hit immediately after this shipped) and never establishes an
549
+ // active-identity pointer at all — reproducing the exact silent-stale-identity bug AUTH-54
550
+ // exists to fix, and permanently hiding that identity from login/logout/status, which only
551
+ // ever look at the new (client, identity)-keyed files. noActiveIdentity forces the same
552
+ // fresh-sign-in path a genuinely first-ever run takes, which is what self-heals into a real
553
+ // active pointer + a new-format file — see the fresh-sign-in branch below.
554
+ const noActiveIdentity = !forceFreshLogin && !active;
555
+
556
+ let tokens = forceRefresh || forceFreshLogin || noActiveIdentity ? null : await readCachedTokens(clientSlug, identityKey);
557
+
558
+ if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
559
+ return { tokens, reusedSilently: true, identityKey, label: active?.label };
380
560
  }
381
561
 
382
- if (!forceRefresh && tokens?.refresh_token) {
562
+ if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token) {
383
563
  try {
384
564
  log("Refreshing cached token...");
385
565
  const fresh = await refreshTokens(tokens.refresh_token);
386
566
  tokens = { ...fresh, obtained_at: Date.now() };
387
- await writeCachedTokens(clientSlug, tokens);
388
- return { tokens, reusedSilently: true };
567
+ await writeCachedTokens(clientSlug, tokens, identityKey);
568
+ return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
389
569
  } catch (err) {
390
570
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
391
571
  }
392
572
  }
393
573
 
394
- if (forceRefresh) await clearCachedTokens(clientSlug);
574
+ if (forceRefresh && identityKey) await clearCachedTokens(clientSlug, identityKey);
395
575
 
396
576
  // Resume an already-in-progress flow (from a process this host killed and respawned)
397
577
  // instead of minting a new device_code the user would have to start over for.
@@ -430,10 +610,16 @@ async function doGetValidTokens(clientSlug, forceRefresh) {
430
610
  try {
431
611
  const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
432
612
  tokens = { ...fresh, obtained_at: Date.now() };
433
- await writeCachedTokens(clientSlug, tokens);
613
+ const freshIdentityKey = identityKeyFromTokens(tokens);
614
+ const freshLabel = identityLabelFromTokens(tokens);
615
+ await writeCachedTokens(clientSlug, tokens, freshIdentityKey);
616
+ // AUTH-54/55: every fresh sign-in (not just an explicit `login`) re-establishes the active
617
+ // pointer — a routine cold start after a cache miss self-heals into a correctly-recorded
618
+ // active identity exactly like it always implicitly did before this item, just now explicit.
619
+ if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
434
620
  await clearPendingFlow(clientSlug);
435
621
  log("Sign-in complete.");
436
- return { tokens, reusedSilently: false };
622
+ return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
437
623
  } catch (err) {
438
624
  // A hard failure (expired/denied, not just this process being killed) means the pending
439
625
  // code is genuinely dead — clear it so the next attempt starts a real fresh one instead
@@ -509,44 +695,55 @@ async function discoverLoopbackServerInfo() {
509
695
  // of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
510
696
  let inFlightLoopbackTokens = null;
511
697
 
512
- function getValidTokensLoopback(clientSlug, forceRefresh = false) {
698
+ function getValidTokensLoopback(clientSlug, forceRefresh = false, forceFreshLogin = false) {
513
699
  if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
514
- inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
700
+ inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin).finally(() => {
515
701
  inFlightLoopbackTokens = null;
516
702
  });
517
703
  return inFlightLoopbackTokens;
518
704
  }
519
705
 
520
- // AUTH-51: returns { tokens, reusedSilently } — see doGetValidTokens's comment above, same
521
- // contract for the loopback mechanism.
522
- async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
706
+ // AUTH-51/54/55: returns { tokens, reusedSilently, identityKey, label } — see
707
+ // doGetValidTokens's comment above, same contract and same forceFreshLogin semantics, for the
708
+ // loopback mechanism.
709
+ async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin = false) {
523
710
  // AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
524
711
  // cached-but-not-yet-expired token from a prior authorization server would otherwise be
525
712
  // returned early below without ever learning the issuer changed underneath it.
526
713
  const serverInfo = await discoverLoopbackServerInfo();
527
714
  const issuerUrl = serverInfo.authorizationServerUrl.toString();
528
715
 
529
- let tokens = forceRefresh ? null : await readLoopbackTokens(clientSlug, issuerUrl);
716
+ const active = forceFreshLogin ? null : await readActiveIdentity(clientSlug);
717
+ const identityKey = active?.identityKey ?? null;
718
+ // See doGetValidTokens's identical guard above for why this is required, not optional: a
719
+ // null identityKey here would otherwise silently resolve to the exact pre-AUTH-54 filename.
720
+ const noActiveIdentity = !forceFreshLogin && !active;
721
+
722
+ let tokens =
723
+ forceRefresh || forceFreshLogin || noActiveIdentity ? null : await readLoopbackTokens(clientSlug, issuerUrl, identityKey);
530
724
 
531
- if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
532
- return { tokens, reusedSilently: true };
725
+ if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
726
+ return { tokens, reusedSilently: true, identityKey, label: active?.label };
533
727
  }
534
728
 
729
+ // AUTH-54: DCR client registration stays clientSlug-only, deliberately not identity-scoped —
730
+ // it identifies this app instance to the authorization server, not the person using it, so
731
+ // every identity signing in from the same client-slug reuses the same registered client_id.
535
732
  let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
536
733
 
537
- if (!forceRefresh && tokens?.refresh_token && clientInformation) {
734
+ if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token && clientInformation) {
538
735
  try {
539
736
  log("Refreshing cached token...");
540
737
  const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
541
738
  tokens = { ...fresh, obtained_at: Date.now() };
542
- await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
543
- return { tokens, reusedSilently: true };
739
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl, identityKey);
740
+ return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
544
741
  } catch (err) {
545
742
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
546
743
  }
547
744
  }
548
745
 
549
- if (forceRefresh) await clearLoopbackTokens(clientSlug);
746
+ if (forceRefresh && identityKey) await clearLoopbackTokens(clientSlug, identityKey);
550
747
 
551
748
  if (!clientInformation) {
552
749
  log("Registering as a new OAuth client...");
@@ -616,9 +813,14 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
616
813
  redirectUri: LOOPBACK_REDIRECT_URI,
617
814
  });
618
815
  tokens = { ...fresh, obtained_at: Date.now() };
619
- await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
816
+ const freshIdentityKey = identityKeyFromTokens(tokens);
817
+ const freshLabel = identityLabelFromTokens(tokens);
818
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl, freshIdentityKey);
819
+ // AUTH-54/55: see doGetValidTokens's identical step for the device-code path — a routine
820
+ // cold start self-heals the active pointer the same way an explicit login does.
821
+ if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
620
822
  log("Sign-in complete.");
621
- return { tokens, reusedSilently: false };
823
+ return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
622
824
  }
623
825
 
624
826
  // AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
@@ -746,14 +948,24 @@ async function main() {
746
948
  // redirect, including refreshes and reactive re-auth below.
747
949
  const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
748
950
  getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
749
- clearFreshTokens = () => (isSafari ? clearCachedTokens(clientSlug) : clearLoopbackTokens(clientSlug));
951
+ // AUTH-54: resolves whichever identity is currently active before clearing — clearing the
952
+ // pre-AUTH-54, identity-less file path would silently no-op now that real caches live under
953
+ // an identity-scoped filename.
954
+ clearFreshTokens = async () => {
955
+ const active = await readActiveIdentity(clientSlug);
956
+ return isSafari
957
+ ? clearCachedTokens(clientSlug, active?.identityKey)
958
+ : clearLoopbackTokens(clientSlug, active?.identityKey);
959
+ };
750
960
  freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
751
961
 
752
962
  let reusedSilently = false;
963
+ let identityLabel = null;
753
964
  try {
754
965
  const result = await getFreshTokens(clientSlug);
755
966
  tokens = result.tokens;
756
967
  reusedSilently = result.reusedSilently;
968
+ identityLabel = result.label;
757
969
  } catch (err) {
758
970
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
759
971
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -763,9 +975,15 @@ async function main() {
763
975
  process.exit(1);
764
976
  }
765
977
 
978
+ // AUTH-56: unconditional, on every run (cache-hit or fresh sign-in alike) — unlike the
979
+ // reused-only notification below, this is the persistent record that a running session used
980
+ // this identity, findable later in the MCP host's own log panel rather than only at the
981
+ // instant a transient OS notification fired.
982
+ log(identityLabel ? `Authenticated as ${identityLabel} for client "${clientSlug}".` : `Authenticated for client "${clientSlug}" (no identity claim found on the token).`);
983
+
766
984
  // AUTH-51: the moment that used to be completely silent — a process starting up and
767
985
  // immediately using a credential it never interactively obtained this run.
768
- if (reusedSilently) await notifySessionReused(clientSlug);
986
+ if (reusedSilently) await notifySessionReused(clientSlug, identityLabel);
769
987
 
770
988
  transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
771
989
  requestInit: {
@@ -798,6 +1016,125 @@ async function main() {
798
1016
  }
799
1017
  }
800
1018
 
1019
+ // AUTH-55/57/58: login/logout/status share this — none of them get a client slug from an MCP
1020
+ // `initialize` handshake the way normal bridge operation does (they run standalone), so all
1021
+ // three require it spelled out explicitly rather than guessing.
1022
+ function parseClientFlag(args) {
1023
+ const idx = args.indexOf("--client");
1024
+ if (idx === -1 || !args[idx + 1]) return null;
1025
+ return args[idx + 1];
1026
+ }
1027
+
1028
+ async function requireClientFlag(args, commandName) {
1029
+ const clientSlug = parseClientFlag(args);
1030
+ if (clientSlug) return clientSlug;
1031
+ const known = await listKnownClientSlugs();
1032
+ const suggestion = known.length
1033
+ ? `Known client slugs with a cached identity: ${known.join(", ")}.`
1034
+ : "No client has ever cached an identity on this machine yet.";
1035
+ log(`${commandName} requires --client <slug>. ${suggestion}`);
1036
+ process.exitCode = 1;
1037
+ return null;
1038
+ }
1039
+
1040
+ function deleteCacheFile(path) {
1041
+ if (!path) return Promise.resolve();
1042
+ return unlink(path).catch(() => {});
1043
+ }
1044
+
1045
+ // AUTH-55: the deliberate, one-shot counterpart to normal bridge startup's silent reuse —
1046
+ // always runs a fresh interactive sign-in (forceFreshLogin, see doGetValidTokens/
1047
+ // doGetValidTokensLoopback) via whichever mechanism (device-code vs. loopback) this machine
1048
+ // would actually use at real connect time, then makes whoever signs in the new active identity
1049
+ // for --client, without touching any other identity already cached for it.
1050
+ async function loginCommand(args) {
1051
+ const clientSlug = await requireClientFlag(args, "login");
1052
+ if (!clientSlug) return;
1053
+
1054
+ const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
1055
+ const getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
1056
+ try {
1057
+ const result = await getFreshTokens(clientSlug, false, /* forceFreshLogin */ true);
1058
+ log(`Signed in as ${result.label ?? "(no identity claim found on the token)"} — now the active identity for "${clientSlug}".`);
1059
+ } catch (err) {
1060
+ log(`Login failed: ${err.message}`);
1061
+ process.exitCode = 1;
1062
+ }
1063
+ }
1064
+
1065
+ // AUTH-57: default scope is the active identity only — clearing it (and the active-identity
1066
+ // pointer itself) is enough to make the client's next normal launch fall back to a fresh
1067
+ // interactive sign-in, per AUTH-57's own Given/When/Then; --all additionally clears every
1068
+ // other identity ever cached for this client slug, for a full offboard/cleanup.
1069
+ async function logoutCommand(args) {
1070
+ const clientSlug = await requireClientFlag(args, "logout");
1071
+ if (!clientSlug) return;
1072
+
1073
+ if (args.includes("--all")) {
1074
+ const identities = await listCachedIdentities(clientSlug);
1075
+ if (identities.length === 0) {
1076
+ log(`Nothing cached for "${clientSlug}".`);
1077
+ return;
1078
+ }
1079
+ for (const { identityKey, label } of identities) {
1080
+ await Promise.all([
1081
+ deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, identityKey)),
1082
+ deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey)),
1083
+ ]);
1084
+ log(`Cleared cached identity ${label} for "${clientSlug}".`);
1085
+ }
1086
+ await clearActiveIdentity(clientSlug);
1087
+ return;
1088
+ }
1089
+
1090
+ const active = await readActiveIdentity(clientSlug);
1091
+ if (!active) {
1092
+ log(`"${clientSlug}" has no active identity to log out of.`);
1093
+ return;
1094
+ }
1095
+ await Promise.all([
1096
+ deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, active.identityKey)),
1097
+ deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, active.identityKey)),
1098
+ ]);
1099
+ await clearActiveIdentity(clientSlug);
1100
+ log(`Logged out ${active.label} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
1101
+ }
1102
+
1103
+ // AUTH-58: --client lists that one slug's cached identities; omitting it surveys every client
1104
+ // slug that has cached anything at all, one summary line each.
1105
+ async function statusCommand(args) {
1106
+ const clientSlug = parseClientFlag(args);
1107
+ if (clientSlug) {
1108
+ const identities = await listCachedIdentities(clientSlug);
1109
+ if (identities.length === 0) {
1110
+ log(`No identities cached for "${clientSlug}".`);
1111
+ return;
1112
+ }
1113
+ for (const { label, active } of identities) {
1114
+ log(`${active ? "*" : " "} ${label}${active ? " (active)" : ""} — client "${clientSlug}"`);
1115
+ }
1116
+ return;
1117
+ }
1118
+
1119
+ const slugs = await listKnownClientSlugs();
1120
+ if (slugs.length === 0) {
1121
+ log("No identities cached for any client on this machine.");
1122
+ return;
1123
+ }
1124
+ for (const slug of slugs) {
1125
+ const identities = await listCachedIdentities(slug);
1126
+ const activeLabel = identities.find((i) => i.active)?.label ?? "none";
1127
+ log(`${slug}: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} cached, active = ${activeLabel}`);
1128
+ }
1129
+ }
1130
+
1131
+ function runCli(promise) {
1132
+ promise.catch((err) => {
1133
+ log(`Fatal error: ${err.stack ?? err.message}`);
1134
+ process.exit(1);
1135
+ });
1136
+ }
1137
+
801
1138
  // Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
802
1139
  // must not trigger a live device-auth flow and stdio takeover as a side effect.
803
1140
  // AUTH-29: npx always launches this via the node_modules/.bin symlink, so argv[1] must be
@@ -805,10 +1142,15 @@ async function main() {
805
1142
  const isMainModule =
806
1143
  process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
807
1144
  if (isMainModule) {
808
- main().catch((err) => {
809
- log(`Fatal error: ${err.stack ?? err.message}`);
810
- process.exit(1);
811
- });
1145
+ // AUTH-55/57/58: a subcommand routes to login/logout/status(/whoami); anything else —
1146
+ // including today's only invocation shape, zero args — falls through to the normal MCP
1147
+ // stdio bridge unchanged. This is the one and only new branch point; every existing caller
1148
+ // (roamer-bridge.sh, every plugin/client config) passes no args and is unaffected.
1149
+ const [subcommand, ...rest] = process.argv.slice(2);
1150
+ if (subcommand === "login") runCli(loginCommand(rest));
1151
+ else if (subcommand === "logout") runCli(logoutCommand(rest));
1152
+ else if (subcommand === "status" || subcommand === "whoami") runCli(statusCommand(rest));
1153
+ else runCli(main());
812
1154
  }
813
1155
 
814
1156
  export {
@@ -825,4 +1167,17 @@ export {
825
1167
  writeLoopbackTokens,
826
1168
  readLoopbackClientInfo,
827
1169
  writeLoopbackClientInfo,
1170
+ // AUTH-54/55/57/58
1171
+ cacheFilePath,
1172
+ identityKeyFromTokens,
1173
+ identityLabelFromTokens,
1174
+ readActiveIdentity,
1175
+ writeActiveIdentity,
1176
+ clearActiveIdentity,
1177
+ listCachedIdentities,
1178
+ listKnownClientSlugs,
1179
+ parseClientFlag,
1180
+ loginCommand,
1181
+ logoutCommand,
1182
+ statusCommand,
828
1183
  };