@mathismeadows/roamer-device-auth 1.1.1 → 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.1",
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,6 +34,7 @@ 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");
37
38
 
38
39
  // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
39
40
  // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
@@ -90,6 +91,35 @@ function clearCachedTokens() {
90
91
  return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
91
92
  }
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(() => {});
121
+ }
122
+
93
123
  function expiresSoon(tokens) {
94
124
  // expires_in is optional in the response shape — an absent value means treat the token as
95
125
  // long-lived and rely on a real 401 to trigger re-auth rather than guessing a lifetime.
@@ -178,30 +208,54 @@ async function doGetValidTokens(forceRefresh) {
178
208
 
179
209
  if (forceRefresh) await clearCachedTokens();
180
210
 
181
- log("Starting sign-in...");
182
- const device = await startDeviceFlow();
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
+
183
223
  log(`Go to ${device.verification_uri} and enter code: ${device.user_code}`);
184
224
  // Fire-and-forget: the dialog is how the human actually sees this on macOS; polling below
185
- // must not wait on it being dismissed.
186
- showDeviceCodeDialog(device.verification_uri, device.user_code);
187
- try {
188
- const { default: open } = await import("open");
189
- await open(device.verification_uri_complete);
190
- } catch {
191
- // 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
+ }
192
235
  }
193
236
  // Terminal-visible for any client where a human sees this process's stderr/log output
194
237
  // (Claude Code CLI, VS Code's integrated terminal) — QR codes are a real usability win
195
238
  // for CLI auth over typing an 8-character code by hand.
196
- qrcode.generate(device.verification_uri_complete, { small: true }, (qr) => {
197
- process.stderr.write(`${qr}\n`);
198
- });
239
+ if (!resuming) {
240
+ qrcode.generate(device.verification_uri_complete, { small: true }, (qr) => {
241
+ process.stderr.write(`${qr}\n`);
242
+ });
243
+ }
199
244
 
200
- const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
201
- tokens = { ...fresh, obtained_at: Date.now() };
202
- await writeCachedTokens(tokens);
203
- log("Sign-in complete.");
204
- 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
+ }
205
259
  }
206
260
 
207
261
  async function main() {