@mathismeadows/roamer-device-auth 1.1.0 → 1.1.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "AUTH-25: server-mediated device authorization flow + stdio<->HTTP MCP proxy for Roamer MCP. Used by roamer-bridge.sh for every OS and every default browser (no more Safari-specific branching, AUTH-14) — Cloudflare Access's Managed OAuth has no device-code grant of its own, so RoamerMcp's server implements it and this script talks to that instead of Entra directly.",
@@ -35,10 +35,25 @@ const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
35
35
  const CACHE_DIR = join(homedir(), ".mcp-auth-device");
36
36
  const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
37
37
 
38
+ // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
39
+ // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
40
+ // (access_token, expires_in, obtained_at) with this format, so a plain presence check isn't
41
+ // enough to trust it — confirmed live 2026-08-21: a stale Entra JWT in this exact cache file
42
+ // was blindly reused and rejected by Cloudflare with invalid_token, with no way to detect or
43
+ // recover short of manually deleting the file. This version stamp is the fix.
44
+ const CACHE_VERSION = 1;
45
+
38
46
  function log(message) {
39
47
  process.stderr.write(`[roamer-device-auth] ${message}\n`);
40
48
  }
41
49
 
50
+ // StreamableHTTPClientTransport surfaces the upstream response body in the error message
51
+ // (confirmed live: "Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token",...}")
52
+ // — pattern-match on that rather than a status code, since the SDK doesn't expose one directly.
53
+ function isAuthError(err) {
54
+ return /invalid_token|unauthorized|\b401\b/i.test(err?.message ?? "");
55
+ }
56
+
42
57
  // A native dialog is an OS-level surface, guaranteed visible independent of whether the MCP
43
58
  // host surfaces this process's stderr anywhere a human will see it (confirmed on the old
44
59
  // Entra-direct flow: a stderr-only first version left the user with no way to see the code).
@@ -55,7 +70,11 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
55
70
 
56
71
  async function readCachedTokens() {
57
72
  try {
58
- return JSON.parse(await readFile(CACHE_FILE, "utf8"));
73
+ const tokens = JSON.parse(await readFile(CACHE_FILE, "utf8"));
74
+ // A cache from an incompatible prior format must never be trusted just because it
75
+ // happens to have the right field names — see CACHE_VERSION's comment.
76
+ if (tokens?.cacheVersion !== CACHE_VERSION) return null;
77
+ return tokens;
59
78
  } catch {
60
79
  return null;
61
80
  }
@@ -63,7 +82,12 @@ async function readCachedTokens() {
63
82
 
64
83
  async function writeCachedTokens(tokens) {
65
84
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
66
- await writeFile(CACHE_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
85
+ const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
86
+ await writeFile(CACHE_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
87
+ }
88
+
89
+ function clearCachedTokens() {
90
+ return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
67
91
  }
68
92
 
69
93
  function expiresSoon(tokens) {
@@ -119,14 +143,28 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
119
143
  }
120
144
  }
121
145
 
122
- async function getValidTokens() {
123
- let tokens = await readCachedTokens();
146
+ // Concurrent stdin messages arriving during a refresh/re-auth window must not each kick off
147
+ // their own competing flow — worst case that means multiple device-code dialogs popping up
148
+ // at once, or two refreshes racing on a rotating refresh_token where the loser's retry then
149
+ // forces an unnecessary full sign-in. All concurrent callers await the same in-flight op.
150
+ let inFlightTokens = null;
124
151
 
125
- if (tokens?.access_token && !expiresSoon(tokens)) {
152
+ function getValidTokens(forceRefresh = false) {
153
+ if (inFlightTokens) return inFlightTokens;
154
+ inFlightTokens = doGetValidTokens(forceRefresh).finally(() => {
155
+ inFlightTokens = null;
156
+ });
157
+ return inFlightTokens;
158
+ }
159
+
160
+ async function doGetValidTokens(forceRefresh) {
161
+ let tokens = forceRefresh ? null : await readCachedTokens();
162
+
163
+ if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
126
164
  return tokens;
127
165
  }
128
166
 
129
- if (tokens?.refresh_token) {
167
+ if (!forceRefresh && tokens?.refresh_token) {
130
168
  try {
131
169
  log("Refreshing cached token...");
132
170
  const fresh = await refreshTokens(tokens.refresh_token);
@@ -138,6 +176,8 @@ async function getValidTokens() {
138
176
  }
139
177
  }
140
178
 
179
+ if (forceRefresh) await clearCachedTokens();
180
+
141
181
  log("Starting sign-in...");
142
182
  const device = await startDeviceFlow();
143
183
  log(`Go to ${device.verification_uri} and enter code: ${device.user_code}`);
@@ -175,7 +215,13 @@ async function main() {
175
215
  },
176
216
  });
177
217
 
178
- transport.onerror = (err) => log(`Transport error: ${err.message}`);
218
+ transport.onerror = (err) => {
219
+ log(`Transport error: ${err.message}`);
220
+ // Same reasoning as the send-path retry below: an auth error means whatever's cached is
221
+ // known-bad, so drop it now rather than let the next proactive expiresSoon() check
222
+ // (which only reasons about calendar time) keep handing it out.
223
+ if (isAuthError(err)) clearCachedTokens();
224
+ };
179
225
  await transport.start();
180
226
  log("Connected to remote server using StreamableHTTPClientTransport.");
181
227
 
@@ -200,7 +246,18 @@ async function main() {
200
246
  if (expiresSoon(tokens)) {
201
247
  tokens = await getValidTokens();
202
248
  }
203
- await transport.send(JSON.parse(line));
249
+ try {
250
+ await transport.send(JSON.parse(line));
251
+ } catch (err) {
252
+ // Reactive invalidation: proactive expiry math can't catch everything (server-side
253
+ // revocation, clock skew, or an incompatible cache — see CACHE_VERSION). A real
254
+ // auth failure from the server is the ground truth; when we see one, invalidate
255
+ // whatever we're holding, force a genuinely fresh token, and retry once.
256
+ if (!isAuthError(err)) throw err;
257
+ log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
258
+ tokens = await getValidTokens(true);
259
+ await transport.send(JSON.parse(line));
260
+ }
204
261
  } catch (err) {
205
262
  log(`Send failed: ${err.message}`);
206
263
  }