@mathismeadows/roamer-device-auth 1.5.5 → 1.5.7

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.5.5",
3
+ "version": "1.5.7",
4
4
  "private": false,
5
5
  "mcpName": "com.mathismeadows/roamer-mcp",
6
6
  "type": "module",
@@ -44,7 +44,7 @@ import {
44
44
  refreshAuthorization,
45
45
  } from "@modelcontextprotocol/sdk/client/auth.js";
46
46
  import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
47
- import { realpathSync } from "node:fs";
47
+ import { realpathSync, watch } from "node:fs";
48
48
  import { homedir } from "node:os";
49
49
  import { join } from "node:path";
50
50
  import { setTimeout as sleep } from "node:timers/promises";
@@ -355,6 +355,13 @@ async function readCachedTokens(clientSlug, identityKey = null) {
355
355
  // A cache from an incompatible prior format must never be trusted just because it
356
356
  // happens to have the right field names — see CACHE_VERSION's comment.
357
357
  if (tokens?.cacheVersion !== CACHE_VERSION) return null;
358
+ // AUTH-38/AUTH-device-code-cache-issuer-blind: same protection readLoopbackTokens already
359
+ // has — a token cached against a prior authorization server (e.g. Cloudflare Access
360
+ // Managed OAuth, before AUTH-38) must never be trusted just because cacheVersion matches.
361
+ // No live discovery needed here (unlike the loopback cache): RoamerMcp declares itself as
362
+ // its own AS in its .well-known/oauth-protected-resource document, so the fixed
363
+ // ROAMER_MCP_ORIGIN constant is exactly as authoritative as a freshly-discovered value.
364
+ if (tokens?.issuerUrl !== ROAMER_MCP_ORIGIN) return null;
358
365
  return tokens;
359
366
  } catch {
360
367
  return null;
@@ -368,7 +375,12 @@ async function writeCachedTokens(clientSlug, tokens, identityKey = null) {
368
375
  const path = cacheFilePath("roamer_tokens", clientSlug, identityKey);
369
376
  if (!path) return; // AUTH-51: no usable client identity — never persisted.
370
377
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
371
- const stamped = { ...tokens, cacheVersion: CACHE_VERSION, label: identityLabelFromTokens(tokens) };
378
+ const stamped = {
379
+ ...tokens,
380
+ cacheVersion: CACHE_VERSION,
381
+ issuerUrl: ROAMER_MCP_ORIGIN,
382
+ label: identityLabelFromTokens(tokens),
383
+ };
372
384
  await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
373
385
  }
374
386
 
@@ -957,6 +969,65 @@ function respondWithSignInError(line, err) {
957
969
  process.stdout.write(`${JSON.stringify(response)}\n`);
958
970
  }
959
971
 
972
+ // AUTH-63: pure comparison, exported for direct unit testing — true when the on-disk active
973
+ // identity for this slug no longer matches the identityKey this process resolved at startup.
974
+ // Normalizes both sides through `?? null` since a cleared/never-set active identity reads back
975
+ // as `undefined`, not `null`, and those must compare equal to "no identity" rather than as a
976
+ // spurious change.
977
+ function identityHasChanged(active, identityKey) {
978
+ return (active?.identityKey ?? null) !== (identityKey ?? null);
979
+ }
980
+
981
+ // AUTH-63: fast path (fs.watch) + slow-poll fallback, watching the containing directory rather
982
+ // than the bare active-identity file so a login/logout's delete-then-rewrite cycle survives — a
983
+ // bare-file watch's inode-tied handle dies on Linux/macOS across a delete/recreate. Node's own
984
+ // fs.watch docs say it "is not 100% consistent across platforms, and is unavailable in some
985
+ // situations," and the callback's filename argument isn't guaranteed everywhere — filename is
986
+ // therefore used only as a cheap filter when present (skip re-checking on unrelated CACHE_DIR
987
+ // writes, e.g. ordinary token refreshes), and always falls through to a real check when it's
988
+ // null. The slow-poll interval is what actually guarantees this self-heals even on a
989
+ // platform/filesystem combination where the fs.watch event never fires at all.
990
+ const IDENTITY_WATCHDOG_POLL_INTERVAL_MS = 5 * 60 * 1000;
991
+
992
+ function startIdentityWatchdog(clientSlug, identityKey, shutdown) {
993
+ if (!clientSlug) return () => {}; // nothing cacheable to watch for this run at all
994
+ const activeFileName = `roamer_active__${clientSlug}.json`;
995
+
996
+ const checkForIdentityChange = async () => {
997
+ try {
998
+ const active = await readActiveIdentity(clientSlug);
999
+ if (identityHasChanged(active, identityKey)) {
1000
+ await shutdown(
1001
+ `Active identity for client "${clientSlug}" changed elsewhere (now ${active?.label ?? "signed out"}) — exiting so the host respawns with the current credential.`,
1002
+ );
1003
+ }
1004
+ } catch (err) {
1005
+ // A transient read failure (e.g. caught mid-write) just retries on the next fs.watch
1006
+ // event or the next poll tick — never itself a reason to exit.
1007
+ log(`Identity watchdog check failed (ignoring): ${err.message}`);
1008
+ }
1009
+ };
1010
+
1011
+ let watcher = null;
1012
+ try {
1013
+ watcher = watch(CACHE_DIR, (eventType, filename) => {
1014
+ if (filename && filename !== activeFileName) return;
1015
+ checkForIdentityChange();
1016
+ });
1017
+ watcher.on("error", (err) => log(`Identity watchdog fs.watch error (relying on the slow-poll fallback): ${err.message}`));
1018
+ } catch (err) {
1019
+ log(`Identity watchdog fs.watch unavailable at startup (relying on the slow-poll fallback): ${err.message}`);
1020
+ }
1021
+
1022
+ const interval = setInterval(checkForIdentityChange, IDENTITY_WATCHDOG_POLL_INTERVAL_MS);
1023
+ interval.unref?.(); // a pending poll must never be the reason this process stays alive
1024
+
1025
+ return () => {
1026
+ watcher?.close();
1027
+ clearInterval(interval);
1028
+ };
1029
+ }
1030
+
960
1031
  async function main() {
961
1032
  // AUTH-28: stdin is read (and, until sign-in completes, queued) from the very first tick —
962
1033
  // a cold-cache device-code flow can take minutes, and the MCP host must never see this
@@ -972,6 +1043,10 @@ async function main() {
972
1043
  // connects — see identityResolved below), so roamer://whoami can answer even while the
973
1044
  // real upstream connection is still coming up.
974
1045
  let identityLabel = null;
1046
+ // AUTH-63: this process's own baseline identityKey, captured alongside identityLabel below —
1047
+ // the identity watchdog compares the on-disk active-identity pointer against this value to
1048
+ // notice a login/logout that happened elsewhere.
1049
+ let identityKey = null;
975
1050
  let identityResolved = false;
976
1051
  const pendingWhoamiLines = [];
977
1052
 
@@ -1025,11 +1100,19 @@ async function main() {
1025
1100
  }
1026
1101
  });
1027
1102
 
1028
- process.stdin.on("end", async () => {
1029
- log("stdin closed, shutting down.");
1103
+ // AUTH-63: shared by the stdin-close path below and the identity watchdog — a single,
1104
+ // idempotent clean-shutdown path rather than two places independently closing the transport
1105
+ // and calling process.exit.
1106
+ let shuttingDown = false;
1107
+ const shutdown = async (reason) => {
1108
+ if (shuttingDown) return;
1109
+ shuttingDown = true;
1110
+ log(reason);
1030
1111
  if (transport) await transport.close();
1031
1112
  process.exit(0);
1032
- });
1113
+ };
1114
+
1115
+ process.stdin.on("end", () => shutdown("stdin closed, shutting down."));
1033
1116
 
1034
1117
  log("Local STDIO proxy running. Press Ctrl+C to exit.");
1035
1118
 
@@ -1068,6 +1151,7 @@ async function main() {
1068
1151
  // adds a network round trip to every ordinary client startup in exchange for a readable
1069
1152
  // log line; falls back to the existing oid-based label on any failure.
1070
1153
  identityLabel = (await fetchLiveLabel(result.tokens?.access_token)) ?? result.label;
1154
+ identityKey = result.identityKey ?? null;
1071
1155
  } catch (err) {
1072
1156
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
1073
1157
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -1091,6 +1175,11 @@ async function main() {
1091
1175
  identityResolved = true;
1092
1176
  for (const line of pendingWhoamiLines) respondWhoami(line, clientSlug, identityLabel);
1093
1177
 
1178
+ // AUTH-63: starts watching for a login/logout elsewhere on this same client slug — this
1179
+ // process's own cached identity never hot-swaps, so the only correct response to noticing
1180
+ // it's now stale is a clean self-exit (via `shutdown` above) for the host to respawn from.
1181
+ startIdentityWatchdog(clientSlug, identityKey, shutdown);
1182
+
1094
1183
  // AUTH-51: the moment that used to be completely silent — a process starting up and
1095
1184
  // immediately using a credential it never interactively obtained this run.
1096
1185
  if (reusedSilently) await notifySessionReused(clientSlug, identityLabel);
@@ -1297,4 +1386,7 @@ export {
1297
1386
  // AUTH-60
1298
1387
  isWhoamiResourceRead,
1299
1388
  respondWhoami,
1389
+ // AUTH-63
1390
+ identityHasChanged,
1391
+ startIdentityWatchdog,
1300
1392
  };