@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 +1 -1
- package/roamer-device-auth.mjs +97 -5
package/package.json
CHANGED
package/roamer-device-auth.mjs
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
1029
|
-
|
|
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
|
};
|