@mathismeadows/roamer-device-auth 1.1.3 → 1.2.0

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,9 +1,9 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "type": "module",
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.",
6
+ "description": "AUTH-14/AUTH-25: stdio<->HTTP MCP proxy for Roamer MCP with two auth mechanisms, chosen per-machine by default-browser detection. Safari-default machines use a server-mediated device authorization flow (AUTH-25) since Safari's HTTPS-Only Mode blocks a loopback redirect; every other machine uses a standard direct loopback redirect against Cloudflare Access Managed OAuth instead.",
7
7
  "bin": {
8
8
  "roamer-device-auth": "./roamer-device-auth.mjs"
9
9
  },
@@ -1,16 +1,18 @@
1
1
  #!/usr/bin/env node
2
- // Roamer MCP — server-mediated device authorization flow (AUTH-25).
2
+ // Roamer MCP — server-mediated device authorization flow (AUTH-25), used only for
3
+ // Safari-default clients since AUTH-14's 2026-08-25 revival. Non-Safari clients (and any
4
+ // OS where Safari isn't an option, e.g. Windows/Linux) instead use a standard direct
5
+ // loopback-redirect flow against Cloudflare Access Managed OAuth — see
6
+ // getValidTokensLoopback() below — removing the device-code confirmation screen AUTH-25's
7
+ // prior unified design imposed on every client, Safari or not.
3
8
  //
4
- // Replaces both the old mcp-remote redirect flow (AUTH-11) and this script's own previous
5
- // Entra-direct device-code flow (AUTH-14) a single mechanism for every OS and every
6
- // default browser now, including Safari, which blocked the old loopback-redirect approach
7
- // entirely (WebKitErrorDomain:305, confirmed live 2026-08-20). Cloudflare Access's Managed
8
- // OAuth has no device-code grant of its own (confirmed live the same day device
9
- // authentication is not supported for MCP portals per Cloudflare's own docs), so
10
- // RoamerMcp's server implements the RFC 8628 shape itself, brokering a real Authorization
11
- // Code + PKCE exchange with Cloudflare server-to-server. This script only ever talks plain
12
- // HTTPS to RoamerMcp — no local port, no local TLS certificate, no browser-default
13
- // detection needed at all.
9
+ // Why Safari still needs device-code: Safari's HTTPS-Only Mode unconditionally blocks the
10
+ // loopback flow's plain http:// callback (WebKitErrorDomain:305, confirmed live
11
+ // 2026-08-20), and Cloudflare Access's Managed OAuth has no device-code grant of its own
12
+ // (confirmed live the same day — device authentication is not supported for MCP portals
13
+ // per Cloudflare's own docs), so RoamerMcp's server implements the RFC 8628 shape itself
14
+ // for that case, brokering a real Authorization Code + PKCE exchange with Cloudflare
15
+ // server-to-server. detectDefaultBrowser() below decides which path a given machine takes.
14
16
  //
15
17
  // AUTH-24: this is the publishable source for the @mathismeadows/roamer-device-auth npm
16
18
  // package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
@@ -19,6 +21,14 @@
19
21
  // stdout is reserved for the JSON-RPC protocol channel; all logging goes to stderr.
20
22
 
21
23
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
24
+ import {
25
+ discoverOAuthServerInfo,
26
+ extractWWWAuthenticateParams,
27
+ registerClient,
28
+ startAuthorization,
29
+ exchangeAuthorization,
30
+ refreshAuthorization,
31
+ } from "@modelcontextprotocol/sdk/client/auth.js";
22
32
  import { readFile, writeFile, mkdir } from "node:fs/promises";
23
33
  import { homedir } from "node:os";
24
34
  import { join } from "node:path";
@@ -26,6 +36,7 @@ import { setTimeout as sleep } from "node:timers/promises";
26
36
  import { execFile } from "node:child_process";
27
37
  import { promisify } from "node:util";
28
38
  import { pathToFileURL } from "node:url";
39
+ import { createServer } from "node:http";
29
40
  import qrcode from "qrcode-terminal";
30
41
 
31
42
  const execFileAsync = promisify(execFile);
@@ -37,6 +48,13 @@ const CACHE_DIR = join(homedir(), ".mcp-auth-device");
37
48
  const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
38
49
  const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
39
50
 
51
+ // AUTH-14: the loopback flow's own independent cache — never assumed interchangeable with
52
+ // the device-code cache above, matching this file's existing precedent of not trusting a
53
+ // differently-obtained cache just because it happens to share field names (see
54
+ // CACHE_VERSION's own comment on the mcp-remote-era predecessor of that exact mistake).
55
+ const LOOPBACK_TOKENS_FILE = join(CACHE_DIR, "roamer_loopback_tokens.json");
56
+ const LOOPBACK_CLIENT_FILE = join(CACHE_DIR, "roamer_loopback_client.json");
57
+
40
58
  // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
41
59
  // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
42
60
  // (access_token, expires_in, obtained_at) with this format, so a plain presence check isn't
@@ -92,6 +110,69 @@ function clearCachedTokens() {
92
110
  return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
93
111
  }
94
112
 
113
+ // AUTH-14: loopback-flow cache — a brand-new file, never previously used by any other
114
+ // mechanism, so (unlike CACHE_FILE above) there's no prior-format collision risk to guard
115
+ // against with a version stamp.
116
+ async function readLoopbackTokens() {
117
+ try {
118
+ const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
119
+ return tokens?.access_token ? tokens : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ async function writeLoopbackTokens(tokens) {
126
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
127
+ await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
128
+ }
129
+
130
+ function clearLoopbackTokens() {
131
+ return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
132
+ }
133
+
134
+ // DCR'd client registration is reused across runs — Cloudflare's registration_endpoint has
135
+ // no reason to be hit on every single sign-in, only the first one (or after a reset).
136
+ async function readLoopbackClientInfo() {
137
+ try {
138
+ const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
139
+ return info?.client_id ? info : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ async function writeLoopbackClientInfo(info) {
146
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
147
+ await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(info, null, 2), { mode: 0o600 });
148
+ }
149
+
150
+ // AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
151
+ // Safari isn't an option elsewhere) take the device-code path above, since Safari's
152
+ // HTTPS-Only Mode blocks the loopback callback outright. Everything else, including every
153
+ // non-macOS platform (no Launch Services plist to query, so this always reports "unknown"
154
+ // there — correctly falling through to the loopback path), takes the lighter direct
155
+ // redirect. ROAMER_MCP_AUTH_FLOW overrides detection entirely — used by this file's own
156
+ // test suite to pin a deterministic path regardless of the CI machine's real OS/browser.
157
+ async function detectDefaultBrowser() {
158
+ const override = process.env.ROAMER_MCP_AUTH_FLOW;
159
+ if (override === "device-code") return "com.apple.safari";
160
+ if (override === "loopback") return "unknown";
161
+
162
+ try {
163
+ const plistPath = join(
164
+ homedir(),
165
+ "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
166
+ );
167
+ const { stdout } = await execFileAsync("plutil", ["-convert", "json", "-o", "-", plistPath]);
168
+ const data = JSON.parse(stdout);
169
+ const handler = (data.LSHandlers ?? []).find((h) => h.LSHandlerURLScheme === "http");
170
+ return handler?.LSHandlerRoleAll ?? "unknown";
171
+ } catch {
172
+ return "unknown";
173
+ }
174
+ }
175
+
95
176
  // Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
96
177
  // see a stdio handshake within its own connect timeout — and the interactive device flow
97
178
  // (dialog -> browser -> Cloudflare's consent screen -> poll) routinely takes longer than a
@@ -259,13 +340,179 @@ async function doGetValidTokens(forceRefresh) {
259
340
  }
260
341
  }
261
342
 
343
+ // AUTH-14: fixed, well-known port for the loopback callback — Cloudflare's DCR'd client
344
+ // registration is persisted and reused across every future sign-in (see
345
+ // readLoopbackClientInfo), so the redirect_uri baked into that registration has to stay
346
+ // stable across runs. A fixed port sidesteps re-registering on every single interactive
347
+ // sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
348
+ // project's original AUTH-11/AUTH-14 mcp-remote-based flow already lived with for months.
349
+ const LOOPBACK_PORT = Number(process.env.ROAMER_MCP_OAUTH_PORT ?? 38271);
350
+ const LOOPBACK_REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
351
+
352
+ async function refreshLoopbackTokens(refreshToken, serverInfo, clientInformation) {
353
+ const fresh = await refreshAuthorization(serverInfo.authorizationServerUrl, {
354
+ metadata: serverInfo.authorizationServerMetadata,
355
+ clientInformation,
356
+ refreshToken,
357
+ });
358
+ return { ...fresh, refresh_token: fresh.refresh_token ?? refreshToken };
359
+ }
360
+
361
+ function waitForLoopbackCallback(server) {
362
+ return new Promise((resolve, reject) => {
363
+ server.on("request", (req, res) => {
364
+ const url = new URL(req.url, LOOPBACK_REDIRECT_URI);
365
+ if (url.pathname !== "/callback") {
366
+ res.writeHead(404);
367
+ res.end();
368
+ return;
369
+ }
370
+ const code = url.searchParams.get("code");
371
+ const error = url.searchParams.get("error");
372
+ res.writeHead(200, { "Content-Type": "text/html" });
373
+ res.end(
374
+ error
375
+ ? `<html><body>Sign-in failed: ${error}. You can close this window.</body></html>`
376
+ : `<html><body>Signed in to Roamer MCP. You can close this window.</body></html>`,
377
+ );
378
+ if (error) reject(new Error(`Loopback authorization failed: ${error}`));
379
+ else if (code) resolve(code);
380
+ else reject(new Error("Loopback callback had neither code nor error"));
381
+ });
382
+ });
383
+ }
384
+
385
+ // AUTH-14: must follow the real WWW-Authenticate resource_metadata hint from a live 401,
386
+ // not RoamerMcp's own default .well-known/oauth-protected-resource document, which still
387
+ // mirrors Entra directly and would send a standards-compliant client to the wrong
388
+ // authorization server entirely — confirmed live 2026-08-26 (see thread
389
+ // SAFARI-DEVICE-CODE-UX; the stale default document itself is a separate, lower-priority
390
+ // gap tracked as AUTH-27, not fixed by this flow following the correct header instead).
391
+ async function discoverLoopbackServerInfo() {
392
+ const probe = await fetch(ROAMER_MCP_URL, {
393
+ method: "POST",
394
+ headers: { "Content-Type": "application/json" },
395
+ body: "{}",
396
+ });
397
+ const { resourceMetadataUrl } = extractWWWAuthenticateParams(probe);
398
+ await probe.body?.cancel?.();
399
+ return discoverOAuthServerInfo(ROAMER_MCP_URL, { resourceMetadataUrl });
400
+ }
401
+
402
+ // Same single-flight guard shape as getValidTokens above, kept independent since only one
403
+ // of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
404
+ let inFlightLoopbackTokens = null;
405
+
406
+ function getValidTokensLoopback(forceRefresh = false) {
407
+ if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
408
+ inFlightLoopbackTokens = doGetValidTokensLoopback(forceRefresh).finally(() => {
409
+ inFlightLoopbackTokens = null;
410
+ });
411
+ return inFlightLoopbackTokens;
412
+ }
413
+
414
+ async function doGetValidTokensLoopback(forceRefresh) {
415
+ let tokens = forceRefresh ? null : await readLoopbackTokens();
416
+
417
+ if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
418
+ return tokens;
419
+ }
420
+
421
+ const serverInfo = await discoverLoopbackServerInfo();
422
+ let clientInformation = await readLoopbackClientInfo();
423
+
424
+ if (!forceRefresh && tokens?.refresh_token && clientInformation) {
425
+ try {
426
+ log("Refreshing cached token...");
427
+ const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
428
+ tokens = { ...fresh, obtained_at: Date.now() };
429
+ await writeLoopbackTokens(tokens);
430
+ return tokens;
431
+ } catch (err) {
432
+ log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
433
+ }
434
+ }
435
+
436
+ if (forceRefresh) await clearLoopbackTokens();
437
+
438
+ if (!clientInformation) {
439
+ log("Registering as a new OAuth client with Cloudflare...");
440
+ clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
441
+ metadata: serverInfo.authorizationServerMetadata,
442
+ clientMetadata: {
443
+ client_name: "Roamer MCP (stdio bridge)",
444
+ redirect_uris: [LOOPBACK_REDIRECT_URI],
445
+ grant_types: ["authorization_code", "refresh_token"],
446
+ response_types: ["code"],
447
+ token_endpoint_auth_method: "none",
448
+ },
449
+ });
450
+ await writeLoopbackClientInfo(clientInformation);
451
+ }
452
+
453
+ log("Starting sign-in...");
454
+ const { authorizationUrl, codeVerifier } = await startAuthorization(serverInfo.authorizationServerUrl, {
455
+ metadata: serverInfo.authorizationServerMetadata,
456
+ clientInformation,
457
+ redirectUrl: LOOPBACK_REDIRECT_URI,
458
+ // RFC 8707: Cloudflare's authorization endpoint rejects the request without this
459
+ // (invalid_target / "No resource parameter found") — the exact same requirement
460
+ // DeviceFlowService.cs already hit and fixed server-side for the device-code path
461
+ // (confirmed live 2026-08-21) and just reconfirmed live here 2026-08-26. Only the
462
+ // authorization step needs it; DeviceFlowService.cs's token/refresh calls don't
463
+ // reference it at all, so exchangeAuthorization/refreshAuthorization below don't either.
464
+ resource: new URL(ROAMER_MCP_URL),
465
+ });
466
+
467
+ const server = createServer();
468
+ const callbackPromise = waitForLoopbackCallback(server);
469
+ await new Promise((resolve, reject) => {
470
+ server.once("error", reject);
471
+ server.listen(LOOPBACK_PORT, "127.0.0.1", resolve);
472
+ }).catch((err) => {
473
+ throw new Error(
474
+ `Could not start the local sign-in listener on port ${LOOPBACK_PORT} (${err.message}). ` +
475
+ "Another roamer-device-auth process may already be signing in — wait for it to finish, " +
476
+ "or set ROAMER_MCP_OAUTH_PORT to a free port.",
477
+ );
478
+ });
479
+
480
+ log(`Go to ${authorizationUrl} to sign in.`);
481
+ try {
482
+ const { default: open } = await import("open");
483
+ await open(authorizationUrl.toString());
484
+ } catch {
485
+ // Best-effort only — the logged URL above is the fallback for any environment without a
486
+ // way to auto-launch a browser.
487
+ }
488
+
489
+ let authorizationCode;
490
+ try {
491
+ authorizationCode = await callbackPromise;
492
+ } finally {
493
+ server.close();
494
+ }
495
+
496
+ const fresh = await exchangeAuthorization(serverInfo.authorizationServerUrl, {
497
+ metadata: serverInfo.authorizationServerMetadata,
498
+ clientInformation,
499
+ authorizationCode,
500
+ codeVerifier,
501
+ redirectUri: LOOPBACK_REDIRECT_URI,
502
+ });
503
+ tokens = { ...fresh, obtained_at: Date.now() };
504
+ await writeLoopbackTokens(tokens);
505
+ log("Sign-in complete.");
506
+ return tokens;
507
+ }
508
+
262
509
  // AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
263
510
  // queued while sign-in was still in progress, with identical forwarding/retry behavior.
264
- async function forwardLine(transport, line, getTokens, setTokens) {
511
+ async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens) {
265
512
  try {
266
513
  let tokens = getTokens();
267
514
  if (expiresSoon(tokens)) {
268
- tokens = await getValidTokens();
515
+ tokens = await getFreshTokens();
269
516
  setTokens(tokens);
270
517
  }
271
518
  try {
@@ -277,7 +524,7 @@ async function forwardLine(transport, line, getTokens, setTokens) {
277
524
  // whatever we're holding, force a genuinely fresh token, and retry once.
278
525
  if (!isAuthError(err)) throw err;
279
526
  log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
280
- tokens = await getValidTokens(true);
527
+ tokens = await getFreshTokens(true);
281
528
  setTokens(tokens);
282
529
  await transport.send(JSON.parse(line));
283
530
  }
@@ -319,6 +566,13 @@ async function main() {
319
566
  tokens = fresh;
320
567
  };
321
568
 
569
+ // AUTH-14: assigned below, only after the stdin listener is attached — forwardLine (the
570
+ // only consumer) never runs before `ready` flips true, which itself never happens before
571
+ // these are set, so deferring the assignment past the listener attach is safe and keeps
572
+ // AUTH-28's guarantee (listener attaches before any await) intact.
573
+ let getFreshTokens;
574
+ let clearFreshTokens;
575
+
322
576
  let buffer = "";
323
577
  process.stdin.setEncoding("utf8");
324
578
  process.stdin.on("data", (chunk) => {
@@ -329,7 +583,7 @@ async function main() {
329
583
  buffer = buffer.slice(newlineIndex + 1);
330
584
  if (!line.trim()) continue;
331
585
  if (ready) {
332
- forwardLine(transport, line, getTokens, setTokens);
586
+ forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
333
587
  } else {
334
588
  pendingLines.push(line);
335
589
  }
@@ -344,8 +598,16 @@ async function main() {
344
598
 
345
599
  log("Local STDIO proxy running. Press Ctrl+C to exit.");
346
600
 
601
+ // AUTH-14: decided once per process — detectDefaultBrowser() shells out to Launch
602
+ // Services, no need to re-check mid-session. Safari-default machines keep using the
603
+ // existing device-code mechanism (AUTH-25); everything else uses the lighter loopback
604
+ // redirect, including refreshes and reactive re-auth below.
605
+ const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
606
+ getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
607
+ clearFreshTokens = isSafari ? clearCachedTokens : clearLoopbackTokens;
608
+
347
609
  try {
348
- tokens = await getValidTokens();
610
+ tokens = await getFreshTokens();
349
611
  } catch (err) {
350
612
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
351
613
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -368,7 +630,7 @@ async function main() {
368
630
  // Same reasoning as the send-path retry above: an auth error means whatever's cached is
369
631
  // known-bad, so drop it now rather than let the next proactive expiresSoon() check
370
632
  // (which only reasons about calendar time) keep handing it out.
371
- if (isAuthError(err)) clearCachedTokens();
633
+ if (isAuthError(err)) clearFreshTokens();
372
634
  };
373
635
  // stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
374
636
  // transport.send() delivers it, and transport.onmessage delivers whatever comes back
@@ -382,7 +644,7 @@ async function main() {
382
644
 
383
645
  ready = true;
384
646
  for (const line of pendingLines) {
385
- forwardLine(transport, line, getTokens, setTokens);
647
+ forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
386
648
  }
387
649
  }
388
650
 
@@ -396,4 +658,4 @@ if (isMainModule) {
396
658
  });
397
659
  }
398
660
 
399
- export { respondWithSignInError, forwardLine };
661
+ export { respondWithSignInError, forwardLine, detectDefaultBrowser };