@mathismeadows/roamer-device-auth 1.4.1 → 1.5.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,10 +1,10 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
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,50 @@ 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
+ let tokens = forceRefresh || forceFreshLogin ? null : await readCachedTokens(clientSlug, identityKey);
542
+
543
+ if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
544
+ return { tokens, reusedSilently: true, identityKey, label: active?.label };
380
545
  }
381
546
 
382
- if (!forceRefresh && tokens?.refresh_token) {
547
+ if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token) {
383
548
  try {
384
549
  log("Refreshing cached token...");
385
550
  const fresh = await refreshTokens(tokens.refresh_token);
386
551
  tokens = { ...fresh, obtained_at: Date.now() };
387
- await writeCachedTokens(clientSlug, tokens);
388
- return { tokens, reusedSilently: true };
552
+ await writeCachedTokens(clientSlug, tokens, identityKey);
553
+ return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
389
554
  } catch (err) {
390
555
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
391
556
  }
392
557
  }
393
558
 
394
- if (forceRefresh) await clearCachedTokens(clientSlug);
559
+ if (forceRefresh && identityKey) await clearCachedTokens(clientSlug, identityKey);
395
560
 
396
561
  // Resume an already-in-progress flow (from a process this host killed and respawned)
397
562
  // instead of minting a new device_code the user would have to start over for.
@@ -430,10 +595,16 @@ async function doGetValidTokens(clientSlug, forceRefresh) {
430
595
  try {
431
596
  const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
432
597
  tokens = { ...fresh, obtained_at: Date.now() };
433
- await writeCachedTokens(clientSlug, tokens);
598
+ const freshIdentityKey = identityKeyFromTokens(tokens);
599
+ const freshLabel = identityLabelFromTokens(tokens);
600
+ await writeCachedTokens(clientSlug, tokens, freshIdentityKey);
601
+ // AUTH-54/55: every fresh sign-in (not just an explicit `login`) re-establishes the active
602
+ // pointer — a routine cold start after a cache miss self-heals into a correctly-recorded
603
+ // active identity exactly like it always implicitly did before this item, just now explicit.
604
+ if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
434
605
  await clearPendingFlow(clientSlug);
435
606
  log("Sign-in complete.");
436
- return { tokens, reusedSilently: false };
607
+ return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
437
608
  } catch (err) {
438
609
  // A hard failure (expired/denied, not just this process being killed) means the pending
439
610
  // code is genuinely dead — clear it so the next attempt starts a real fresh one instead
@@ -509,44 +680,51 @@ async function discoverLoopbackServerInfo() {
509
680
  // of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
510
681
  let inFlightLoopbackTokens = null;
511
682
 
512
- function getValidTokensLoopback(clientSlug, forceRefresh = false) {
683
+ function getValidTokensLoopback(clientSlug, forceRefresh = false, forceFreshLogin = false) {
513
684
  if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
514
- inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
685
+ inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin).finally(() => {
515
686
  inFlightLoopbackTokens = null;
516
687
  });
517
688
  return inFlightLoopbackTokens;
518
689
  }
519
690
 
520
- // AUTH-51: returns { tokens, reusedSilently } — see doGetValidTokens's comment above, same
521
- // contract for the loopback mechanism.
522
- async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
691
+ // AUTH-51/54/55: returns { tokens, reusedSilently, identityKey, label } — see
692
+ // doGetValidTokens's comment above, same contract and same forceFreshLogin semantics, for the
693
+ // loopback mechanism.
694
+ async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin = false) {
523
695
  // AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
524
696
  // cached-but-not-yet-expired token from a prior authorization server would otherwise be
525
697
  // returned early below without ever learning the issuer changed underneath it.
526
698
  const serverInfo = await discoverLoopbackServerInfo();
527
699
  const issuerUrl = serverInfo.authorizationServerUrl.toString();
528
700
 
529
- let tokens = forceRefresh ? null : await readLoopbackTokens(clientSlug, issuerUrl);
701
+ const active = forceFreshLogin ? null : await readActiveIdentity(clientSlug);
702
+ const identityKey = active?.identityKey ?? null;
703
+
704
+ let tokens = forceRefresh || forceFreshLogin ? null : await readLoopbackTokens(clientSlug, issuerUrl, identityKey);
530
705
 
531
- if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
532
- return { tokens, reusedSilently: true };
706
+ if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
707
+ return { tokens, reusedSilently: true, identityKey, label: active?.label };
533
708
  }
534
709
 
710
+ // AUTH-54: DCR client registration stays clientSlug-only, deliberately not identity-scoped —
711
+ // it identifies this app instance to the authorization server, not the person using it, so
712
+ // every identity signing in from the same client-slug reuses the same registered client_id.
535
713
  let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
536
714
 
537
- if (!forceRefresh && tokens?.refresh_token && clientInformation) {
715
+ if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token && clientInformation) {
538
716
  try {
539
717
  log("Refreshing cached token...");
540
718
  const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
541
719
  tokens = { ...fresh, obtained_at: Date.now() };
542
- await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
543
- return { tokens, reusedSilently: true };
720
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl, identityKey);
721
+ return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
544
722
  } catch (err) {
545
723
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
546
724
  }
547
725
  }
548
726
 
549
- if (forceRefresh) await clearLoopbackTokens(clientSlug);
727
+ if (forceRefresh && identityKey) await clearLoopbackTokens(clientSlug, identityKey);
550
728
 
551
729
  if (!clientInformation) {
552
730
  log("Registering as a new OAuth client...");
@@ -616,9 +794,14 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
616
794
  redirectUri: LOOPBACK_REDIRECT_URI,
617
795
  });
618
796
  tokens = { ...fresh, obtained_at: Date.now() };
619
- await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
797
+ const freshIdentityKey = identityKeyFromTokens(tokens);
798
+ const freshLabel = identityLabelFromTokens(tokens);
799
+ await writeLoopbackTokens(clientSlug, tokens, issuerUrl, freshIdentityKey);
800
+ // AUTH-54/55: see doGetValidTokens's identical step for the device-code path — a routine
801
+ // cold start self-heals the active pointer the same way an explicit login does.
802
+ if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
620
803
  log("Sign-in complete.");
621
- return { tokens, reusedSilently: false };
804
+ return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
622
805
  }
623
806
 
624
807
  // AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
@@ -746,14 +929,24 @@ async function main() {
746
929
  // redirect, including refreshes and reactive re-auth below.
747
930
  const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
748
931
  getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
749
- clearFreshTokens = () => (isSafari ? clearCachedTokens(clientSlug) : clearLoopbackTokens(clientSlug));
932
+ // AUTH-54: resolves whichever identity is currently active before clearing — clearing the
933
+ // pre-AUTH-54, identity-less file path would silently no-op now that real caches live under
934
+ // an identity-scoped filename.
935
+ clearFreshTokens = async () => {
936
+ const active = await readActiveIdentity(clientSlug);
937
+ return isSafari
938
+ ? clearCachedTokens(clientSlug, active?.identityKey)
939
+ : clearLoopbackTokens(clientSlug, active?.identityKey);
940
+ };
750
941
  freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
751
942
 
752
943
  let reusedSilently = false;
944
+ let identityLabel = null;
753
945
  try {
754
946
  const result = await getFreshTokens(clientSlug);
755
947
  tokens = result.tokens;
756
948
  reusedSilently = result.reusedSilently;
949
+ identityLabel = result.label;
757
950
  } catch (err) {
758
951
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
759
952
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -763,9 +956,15 @@ async function main() {
763
956
  process.exit(1);
764
957
  }
765
958
 
959
+ // AUTH-56: unconditional, on every run (cache-hit or fresh sign-in alike) — unlike the
960
+ // reused-only notification below, this is the persistent record that a running session used
961
+ // this identity, findable later in the MCP host's own log panel rather than only at the
962
+ // instant a transient OS notification fired.
963
+ log(identityLabel ? `Authenticated as ${identityLabel} for client "${clientSlug}".` : `Authenticated for client "${clientSlug}" (no identity claim found on the token).`);
964
+
766
965
  // AUTH-51: the moment that used to be completely silent — a process starting up and
767
966
  // immediately using a credential it never interactively obtained this run.
768
- if (reusedSilently) await notifySessionReused(clientSlug);
967
+ if (reusedSilently) await notifySessionReused(clientSlug, identityLabel);
769
968
 
770
969
  transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
771
970
  requestInit: {
@@ -798,6 +997,125 @@ async function main() {
798
997
  }
799
998
  }
800
999
 
1000
+ // AUTH-55/57/58: login/logout/status share this — none of them get a client slug from an MCP
1001
+ // `initialize` handshake the way normal bridge operation does (they run standalone), so all
1002
+ // three require it spelled out explicitly rather than guessing.
1003
+ function parseClientFlag(args) {
1004
+ const idx = args.indexOf("--client");
1005
+ if (idx === -1 || !args[idx + 1]) return null;
1006
+ return args[idx + 1];
1007
+ }
1008
+
1009
+ async function requireClientFlag(args, commandName) {
1010
+ const clientSlug = parseClientFlag(args);
1011
+ if (clientSlug) return clientSlug;
1012
+ const known = await listKnownClientSlugs();
1013
+ const suggestion = known.length
1014
+ ? `Known client slugs with a cached identity: ${known.join(", ")}.`
1015
+ : "No client has ever cached an identity on this machine yet.";
1016
+ log(`${commandName} requires --client <slug>. ${suggestion}`);
1017
+ process.exitCode = 1;
1018
+ return null;
1019
+ }
1020
+
1021
+ function deleteCacheFile(path) {
1022
+ if (!path) return Promise.resolve();
1023
+ return unlink(path).catch(() => {});
1024
+ }
1025
+
1026
+ // AUTH-55: the deliberate, one-shot counterpart to normal bridge startup's silent reuse —
1027
+ // always runs a fresh interactive sign-in (forceFreshLogin, see doGetValidTokens/
1028
+ // doGetValidTokensLoopback) via whichever mechanism (device-code vs. loopback) this machine
1029
+ // would actually use at real connect time, then makes whoever signs in the new active identity
1030
+ // for --client, without touching any other identity already cached for it.
1031
+ async function loginCommand(args) {
1032
+ const clientSlug = await requireClientFlag(args, "login");
1033
+ if (!clientSlug) return;
1034
+
1035
+ const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
1036
+ const getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
1037
+ try {
1038
+ const result = await getFreshTokens(clientSlug, false, /* forceFreshLogin */ true);
1039
+ log(`Signed in as ${result.label ?? "(no identity claim found on the token)"} — now the active identity for "${clientSlug}".`);
1040
+ } catch (err) {
1041
+ log(`Login failed: ${err.message}`);
1042
+ process.exitCode = 1;
1043
+ }
1044
+ }
1045
+
1046
+ // AUTH-57: default scope is the active identity only — clearing it (and the active-identity
1047
+ // pointer itself) is enough to make the client's next normal launch fall back to a fresh
1048
+ // interactive sign-in, per AUTH-57's own Given/When/Then; --all additionally clears every
1049
+ // other identity ever cached for this client slug, for a full offboard/cleanup.
1050
+ async function logoutCommand(args) {
1051
+ const clientSlug = await requireClientFlag(args, "logout");
1052
+ if (!clientSlug) return;
1053
+
1054
+ if (args.includes("--all")) {
1055
+ const identities = await listCachedIdentities(clientSlug);
1056
+ if (identities.length === 0) {
1057
+ log(`Nothing cached for "${clientSlug}".`);
1058
+ return;
1059
+ }
1060
+ for (const { identityKey, label } of identities) {
1061
+ await Promise.all([
1062
+ deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, identityKey)),
1063
+ deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey)),
1064
+ ]);
1065
+ log(`Cleared cached identity ${label} for "${clientSlug}".`);
1066
+ }
1067
+ await clearActiveIdentity(clientSlug);
1068
+ return;
1069
+ }
1070
+
1071
+ const active = await readActiveIdentity(clientSlug);
1072
+ if (!active) {
1073
+ log(`"${clientSlug}" has no active identity to log out of.`);
1074
+ return;
1075
+ }
1076
+ await Promise.all([
1077
+ deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, active.identityKey)),
1078
+ deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, active.identityKey)),
1079
+ ]);
1080
+ await clearActiveIdentity(clientSlug);
1081
+ log(`Logged out ${active.label} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
1082
+ }
1083
+
1084
+ // AUTH-58: --client lists that one slug's cached identities; omitting it surveys every client
1085
+ // slug that has cached anything at all, one summary line each.
1086
+ async function statusCommand(args) {
1087
+ const clientSlug = parseClientFlag(args);
1088
+ if (clientSlug) {
1089
+ const identities = await listCachedIdentities(clientSlug);
1090
+ if (identities.length === 0) {
1091
+ log(`No identities cached for "${clientSlug}".`);
1092
+ return;
1093
+ }
1094
+ for (const { label, active } of identities) {
1095
+ log(`${active ? "*" : " "} ${label}${active ? " (active)" : ""} — client "${clientSlug}"`);
1096
+ }
1097
+ return;
1098
+ }
1099
+
1100
+ const slugs = await listKnownClientSlugs();
1101
+ if (slugs.length === 0) {
1102
+ log("No identities cached for any client on this machine.");
1103
+ return;
1104
+ }
1105
+ for (const slug of slugs) {
1106
+ const identities = await listCachedIdentities(slug);
1107
+ const activeLabel = identities.find((i) => i.active)?.label ?? "none";
1108
+ log(`${slug}: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} cached, active = ${activeLabel}`);
1109
+ }
1110
+ }
1111
+
1112
+ function runCli(promise) {
1113
+ promise.catch((err) => {
1114
+ log(`Fatal error: ${err.stack ?? err.message}`);
1115
+ process.exit(1);
1116
+ });
1117
+ }
1118
+
801
1119
  // Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
802
1120
  // must not trigger a live device-auth flow and stdio takeover as a side effect.
803
1121
  // AUTH-29: npx always launches this via the node_modules/.bin symlink, so argv[1] must be
@@ -805,10 +1123,15 @@ async function main() {
805
1123
  const isMainModule =
806
1124
  process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
807
1125
  if (isMainModule) {
808
- main().catch((err) => {
809
- log(`Fatal error: ${err.stack ?? err.message}`);
810
- process.exit(1);
811
- });
1126
+ // AUTH-55/57/58: a subcommand routes to login/logout/status(/whoami); anything else —
1127
+ // including today's only invocation shape, zero args — falls through to the normal MCP
1128
+ // stdio bridge unchanged. This is the one and only new branch point; every existing caller
1129
+ // (roamer-bridge.sh, every plugin/client config) passes no args and is unaffected.
1130
+ const [subcommand, ...rest] = process.argv.slice(2);
1131
+ if (subcommand === "login") runCli(loginCommand(rest));
1132
+ else if (subcommand === "logout") runCli(logoutCommand(rest));
1133
+ else if (subcommand === "status" || subcommand === "whoami") runCli(statusCommand(rest));
1134
+ else runCli(main());
812
1135
  }
813
1136
 
814
1137
  export {
@@ -825,4 +1148,17 @@ export {
825
1148
  writeLoopbackTokens,
826
1149
  readLoopbackClientInfo,
827
1150
  writeLoopbackClientInfo,
1151
+ // AUTH-54/55/57/58
1152
+ cacheFilePath,
1153
+ identityKeyFromTokens,
1154
+ identityLabelFromTokens,
1155
+ readActiveIdentity,
1156
+ writeActiveIdentity,
1157
+ clearActiveIdentity,
1158
+ listCachedIdentities,
1159
+ listKnownClientSlugs,
1160
+ parseClientFlag,
1161
+ loginCommand,
1162
+ logoutCommand,
1163
+ statusCommand,
828
1164
  };