@mathismeadows/roamer-device-auth 1.1.0 → 1.1.2

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.2",
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.",
@@ -34,11 +34,27 @@ const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
34
34
 
35
35
  const CACHE_DIR = join(homedir(), ".mcp-auth-device");
36
36
  const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
37
+ const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
38
+
39
+ // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
40
+ // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
41
+ // (access_token, expires_in, obtained_at) with this format, so a plain presence check isn't
42
+ // enough to trust it — confirmed live 2026-08-21: a stale Entra JWT in this exact cache file
43
+ // was blindly reused and rejected by Cloudflare with invalid_token, with no way to detect or
44
+ // recover short of manually deleting the file. This version stamp is the fix.
45
+ const CACHE_VERSION = 1;
37
46
 
38
47
  function log(message) {
39
48
  process.stderr.write(`[roamer-device-auth] ${message}\n`);
40
49
  }
41
50
 
51
+ // StreamableHTTPClientTransport surfaces the upstream response body in the error message
52
+ // (confirmed live: "Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token",...}")
53
+ // — pattern-match on that rather than a status code, since the SDK doesn't expose one directly.
54
+ function isAuthError(err) {
55
+ return /invalid_token|unauthorized|\b401\b/i.test(err?.message ?? "");
56
+ }
57
+
42
58
  // A native dialog is an OS-level surface, guaranteed visible independent of whether the MCP
43
59
  // host surfaces this process's stderr anywhere a human will see it (confirmed on the old
44
60
  // Entra-direct flow: a stderr-only first version left the user with no way to see the code).
@@ -55,7 +71,11 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
55
71
 
56
72
  async function readCachedTokens() {
57
73
  try {
58
- return JSON.parse(await readFile(CACHE_FILE, "utf8"));
74
+ const tokens = JSON.parse(await readFile(CACHE_FILE, "utf8"));
75
+ // A cache from an incompatible prior format must never be trusted just because it
76
+ // happens to have the right field names — see CACHE_VERSION's comment.
77
+ if (tokens?.cacheVersion !== CACHE_VERSION) return null;
78
+ return tokens;
59
79
  } catch {
60
80
  return null;
61
81
  }
@@ -63,7 +83,41 @@ async function readCachedTokens() {
63
83
 
64
84
  async function writeCachedTokens(tokens) {
65
85
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
66
- await writeFile(CACHE_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
86
+ const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
87
+ await writeFile(CACHE_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
88
+ }
89
+
90
+ function clearCachedTokens() {
91
+ return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
92
+ }
93
+
94
+ // Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
95
+ // see a stdio handshake within its own connect timeout — and the interactive device flow
96
+ // (dialog -> browser -> Cloudflare's consent screen -> poll) routinely takes longer than a
97
+ // human can click through inside a short timeout. Without persisting the in-progress flow,
98
+ // every respawn called /oauth/device/start again, which mints a BRAND NEW device_code/
99
+ // user_code — the user would see a different code every single retry, forever, with no
100
+ // path to ever actually finish signing in. Confirmed live 2026-08-21. Persisting the
101
+ // pending flow means a respawned process resumes polling the SAME still-valid code instead.
102
+ async function readPendingFlow() {
103
+ try {
104
+ const pending = JSON.parse(await readFile(PENDING_FILE, "utf8"));
105
+ if (!pending?.device_code || Date.now() > pending.expiresAt) return null;
106
+ return pending;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ async function writePendingFlow(device) {
113
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
114
+ const pending = { ...device, expiresAt: Date.now() + device.expires_in * 1000 };
115
+ await writeFile(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
116
+ return pending;
117
+ }
118
+
119
+ function clearPendingFlow() {
120
+ return writeFile(PENDING_FILE, "{}", { mode: 0o600 }).catch(() => {});
67
121
  }
68
122
 
69
123
  function expiresSoon(tokens) {
@@ -119,14 +173,28 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
119
173
  }
120
174
  }
121
175
 
122
- async function getValidTokens() {
123
- let tokens = await readCachedTokens();
176
+ // Concurrent stdin messages arriving during a refresh/re-auth window must not each kick off
177
+ // their own competing flow — worst case that means multiple device-code dialogs popping up
178
+ // at once, or two refreshes racing on a rotating refresh_token where the loser's retry then
179
+ // forces an unnecessary full sign-in. All concurrent callers await the same in-flight op.
180
+ let inFlightTokens = null;
181
+
182
+ function getValidTokens(forceRefresh = false) {
183
+ if (inFlightTokens) return inFlightTokens;
184
+ inFlightTokens = doGetValidTokens(forceRefresh).finally(() => {
185
+ inFlightTokens = null;
186
+ });
187
+ return inFlightTokens;
188
+ }
189
+
190
+ async function doGetValidTokens(forceRefresh) {
191
+ let tokens = forceRefresh ? null : await readCachedTokens();
124
192
 
125
- if (tokens?.access_token && !expiresSoon(tokens)) {
193
+ if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
126
194
  return tokens;
127
195
  }
128
196
 
129
- if (tokens?.refresh_token) {
197
+ if (!forceRefresh && tokens?.refresh_token) {
130
198
  try {
131
199
  log("Refreshing cached token...");
132
200
  const fresh = await refreshTokens(tokens.refresh_token);
@@ -138,30 +206,56 @@ async function getValidTokens() {
138
206
  }
139
207
  }
140
208
 
141
- log("Starting sign-in...");
142
- const device = await startDeviceFlow();
209
+ if (forceRefresh) await clearCachedTokens();
210
+
211
+ // Resume an already-in-progress flow (from a process this host killed and respawned)
212
+ // instead of minting a new device_code the user would have to start over for.
213
+ let device = await readPendingFlow();
214
+ let resuming = Boolean(device);
215
+ if (!resuming) {
216
+ log("Starting sign-in...");
217
+ device = await startDeviceFlow();
218
+ await writePendingFlow(device);
219
+ } else {
220
+ log("Resuming an already-in-progress sign-in (a prior process was restarted before it finished)...");
221
+ }
222
+
143
223
  log(`Go to ${device.verification_uri} and enter code: ${device.user_code}`);
144
224
  // Fire-and-forget: the dialog is how the human actually sees this on macOS; polling below
145
- // must not wait on it being dismissed.
146
- showDeviceCodeDialog(device.verification_uri, device.user_code);
147
- try {
148
- const { default: open } = await import("open");
149
- await open(device.verification_uri_complete);
150
- } catch {
151
- // Best-effort only — the dialog and QR code below already carry the URL and code.
225
+ // must not wait on it being dismissed. Skip re-showing it on a resume — the human already
226
+ // saw it (or is mid-flow in the browser) from the process that started this same code.
227
+ if (!resuming) showDeviceCodeDialog(device.verification_uri, device.user_code);
228
+ if (!resuming) {
229
+ try {
230
+ const { default: open } = await import("open");
231
+ await open(device.verification_uri_complete);
232
+ } catch {
233
+ // Best-effort only — the dialog and QR code below already carry the URL and code.
234
+ }
152
235
  }
153
236
  // Terminal-visible for any client where a human sees this process's stderr/log output
154
237
  // (Claude Code CLI, VS Code's integrated terminal) — QR codes are a real usability win
155
238
  // for CLI auth over typing an 8-character code by hand.
156
- qrcode.generate(device.verification_uri_complete, { small: true }, (qr) => {
157
- process.stderr.write(`${qr}\n`);
158
- });
239
+ if (!resuming) {
240
+ qrcode.generate(device.verification_uri_complete, { small: true }, (qr) => {
241
+ process.stderr.write(`${qr}\n`);
242
+ });
243
+ }
159
244
 
160
- const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
161
- tokens = { ...fresh, obtained_at: Date.now() };
162
- await writeCachedTokens(tokens);
163
- log("Sign-in complete.");
164
- return tokens;
245
+ try {
246
+ const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
247
+ tokens = { ...fresh, obtained_at: Date.now() };
248
+ await writeCachedTokens(tokens);
249
+ await clearPendingFlow();
250
+ log("Sign-in complete.");
251
+ return tokens;
252
+ } catch (err) {
253
+ // A hard failure (expired/denied, not just this process being killed) means the pending
254
+ // code is genuinely dead — clear it so the next attempt starts a real fresh one instead
255
+ // of retrying a code that will never succeed.
256
+ await clearPendingFlow();
257
+ throw err;
258
+ }
165
259
  }
166
260
 
167
261
  async function main() {
@@ -175,7 +269,13 @@ async function main() {
175
269
  },
176
270
  });
177
271
 
178
- transport.onerror = (err) => log(`Transport error: ${err.message}`);
272
+ transport.onerror = (err) => {
273
+ log(`Transport error: ${err.message}`);
274
+ // Same reasoning as the send-path retry below: an auth error means whatever's cached is
275
+ // known-bad, so drop it now rather than let the next proactive expiresSoon() check
276
+ // (which only reasons about calendar time) keep handing it out.
277
+ if (isAuthError(err)) clearCachedTokens();
278
+ };
179
279
  await transport.start();
180
280
  log("Connected to remote server using StreamableHTTPClientTransport.");
181
281
 
@@ -200,7 +300,18 @@ async function main() {
200
300
  if (expiresSoon(tokens)) {
201
301
  tokens = await getValidTokens();
202
302
  }
203
- await transport.send(JSON.parse(line));
303
+ try {
304
+ await transport.send(JSON.parse(line));
305
+ } catch (err) {
306
+ // Reactive invalidation: proactive expiry math can't catch everything (server-side
307
+ // revocation, clock skew, or an incompatible cache — see CACHE_VERSION). A real
308
+ // auth failure from the server is the ground truth; when we see one, invalidate
309
+ // whatever we're holding, force a genuinely fresh token, and retry once.
310
+ if (!isAuthError(err)) throw err;
311
+ log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
312
+ tokens = await getValidTokens(true);
313
+ await transport.send(JSON.parse(line));
314
+ }
204
315
  } catch (err) {
205
316
  log(`Send failed: ${err.message}`);
206
317
  }