@mathismeadows/roamer-device-auth 1.1.2 → 1.1.3

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.2",
3
+ "version": "1.1.3",
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.",
@@ -25,6 +25,7 @@ import { join } from "node:path";
25
25
  import { setTimeout as sleep } from "node:timers/promises";
26
26
  import { execFile } from "node:child_process";
27
27
  import { promisify } from "node:util";
28
+ import { pathToFileURL } from "node:url";
28
29
  import qrcode from "qrcode-terminal";
29
30
 
30
31
  const execFileAsync = promisify(execFile);
@@ -258,10 +259,103 @@ async function doGetValidTokens(forceRefresh) {
258
259
  }
259
260
  }
260
261
 
262
+ // AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
263
+ // queued while sign-in was still in progress, with identical forwarding/retry behavior.
264
+ async function forwardLine(transport, line, getTokens, setTokens) {
265
+ try {
266
+ let tokens = getTokens();
267
+ if (expiresSoon(tokens)) {
268
+ tokens = await getValidTokens();
269
+ setTokens(tokens);
270
+ }
271
+ try {
272
+ await transport.send(JSON.parse(line));
273
+ } catch (err) {
274
+ // Reactive invalidation: proactive expiry math can't catch everything (server-side
275
+ // revocation, clock skew, or an incompatible cache — see CACHE_VERSION). A real
276
+ // auth failure from the server is the ground truth; when we see one, invalidate
277
+ // whatever we're holding, force a genuinely fresh token, and retry once.
278
+ if (!isAuthError(err)) throw err;
279
+ log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
280
+ tokens = await getValidTokens(true);
281
+ setTokens(tokens);
282
+ await transport.send(JSON.parse(line));
283
+ }
284
+ } catch (err) {
285
+ log(`Send failed: ${err.message}`);
286
+ }
287
+ }
288
+
289
+ // AUTH-28: a hard sign-in failure (denied/expired device code) must tell the host exactly
290
+ // that, per request, rather than leaving requests unanswered for the host to time out on.
291
+ function respondWithSignInError(line, err) {
292
+ let id = null;
293
+ try {
294
+ id = JSON.parse(line)?.id ?? null;
295
+ } catch {
296
+ // Malformed input never had a usable id anyway — respond with null per JSON-RPC convention.
297
+ }
298
+ const response = {
299
+ jsonrpc: "2.0",
300
+ id,
301
+ error: { code: -32001, message: `Roamer MCP sign-in failed: ${err.message}` },
302
+ };
303
+ process.stdout.write(`${JSON.stringify(response)}\n`);
304
+ }
305
+
261
306
  async function main() {
262
- let tokens = await getValidTokens();
307
+ // AUTH-28: stdin is read (and, until sign-in completes, queued) from the very first tick —
308
+ // a cold-cache device-code flow can take minutes, and the MCP host must never see this
309
+ // process as unresponsive/silent during that window. That silence, not the auth flow
310
+ // itself, is what triggers the host's connect-timeout kill/respawn (see readPendingFlow's
311
+ // comment above) — this fix targets the silence, not the flow's real, unavoidable duration.
312
+ let tokens = null;
313
+ let transport = null;
314
+ let ready = false;
315
+ const pendingLines = [];
316
+
317
+ const getTokens = () => tokens;
318
+ const setTokens = (fresh) => {
319
+ tokens = fresh;
320
+ };
263
321
 
264
- const transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
322
+ let buffer = "";
323
+ process.stdin.setEncoding("utf8");
324
+ process.stdin.on("data", (chunk) => {
325
+ buffer += chunk;
326
+ let newlineIndex;
327
+ while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
328
+ const line = buffer.slice(0, newlineIndex);
329
+ buffer = buffer.slice(newlineIndex + 1);
330
+ if (!line.trim()) continue;
331
+ if (ready) {
332
+ forwardLine(transport, line, getTokens, setTokens);
333
+ } else {
334
+ pendingLines.push(line);
335
+ }
336
+ }
337
+ });
338
+
339
+ process.stdin.on("end", async () => {
340
+ log("stdin closed, shutting down.");
341
+ if (transport) await transport.close();
342
+ process.exit(0);
343
+ });
344
+
345
+ log("Local STDIO proxy running. Press Ctrl+C to exit.");
346
+
347
+ try {
348
+ tokens = await getValidTokens();
349
+ } catch (err) {
350
+ // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
351
+ // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
352
+ // explicit failure instead of a process it has to silently time out on.
353
+ log(`Sign-in failed: ${err.message}`);
354
+ for (const line of pendingLines) respondWithSignInError(line, err);
355
+ process.exit(1);
356
+ }
357
+
358
+ transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
265
359
  requestInit: {
266
360
  get headers() {
267
361
  return { Authorization: `Bearer ${tokens.access_token}` };
@@ -271,14 +365,11 @@ async function main() {
271
365
 
272
366
  transport.onerror = (err) => {
273
367
  log(`Transport error: ${err.message}`);
274
- // Same reasoning as the send-path retry below: an auth error means whatever's cached is
368
+ // Same reasoning as the send-path retry above: an auth error means whatever's cached is
275
369
  // known-bad, so drop it now rather than let the next proactive expiresSoon() check
276
370
  // (which only reasons about calendar time) keep handing it out.
277
371
  if (isAuthError(err)) clearCachedTokens();
278
372
  };
279
- await transport.start();
280
- log("Connected to remote server using StreamableHTTPClientTransport.");
281
-
282
373
  // stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
283
374
  // transport.send() delivers it, and transport.onmessage delivers whatever comes back
284
375
  // (including server-initiated messages over the SSE half of the streamable-HTTP transport).
@@ -286,49 +377,23 @@ async function main() {
286
377
  process.stdout.write(`${JSON.stringify(message)}\n`);
287
378
  };
288
379
 
289
- let buffer = "";
290
- process.stdin.setEncoding("utf8");
291
- process.stdin.on("data", (chunk) => {
292
- buffer += chunk;
293
- let newlineIndex;
294
- while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
295
- const line = buffer.slice(0, newlineIndex);
296
- buffer = buffer.slice(newlineIndex + 1);
297
- if (!line.trim()) continue;
298
- (async () => {
299
- try {
300
- if (expiresSoon(tokens)) {
301
- tokens = await getValidTokens();
302
- }
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
- }
315
- } catch (err) {
316
- log(`Send failed: ${err.message}`);
317
- }
318
- })();
319
- }
320
- });
380
+ await transport.start();
381
+ log("Connected to remote server using StreamableHTTPClientTransport.");
321
382
 
322
- process.stdin.on("end", async () => {
323
- log("stdin closed, shutting down.");
324
- await transport.close();
325
- process.exit(0);
326
- });
383
+ ready = true;
384
+ for (const line of pendingLines) {
385
+ forwardLine(transport, line, getTokens, setTokens);
386
+ }
387
+ }
327
388
 
328
- log("Local STDIO proxy running. Press Ctrl+C to exit.");
389
+ // Only auto-run when invoked directly (npx/CLI) importing this module from a test file
390
+ // must not trigger a live device-auth flow and stdio takeover as a side effect.
391
+ const isMainModule = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
392
+ if (isMainModule) {
393
+ main().catch((err) => {
394
+ log(`Fatal error: ${err.stack ?? err.message}`);
395
+ process.exit(1);
396
+ });
329
397
  }
330
398
 
331
- main().catch((err) => {
332
- log(`Fatal error: ${err.stack ?? err.message}`);
333
- process.exit(1);
334
- });
399
+ export { respondWithSignInError, forwardLine };