@mathismeadows/roamer-device-auth 1.2.0 → 1.3.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.2.0",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "type": "module",
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.",
6
+ "description": "AUTH-14/AUTH-25/AUTH-38: 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 instead. Both authenticate against RoamerMcp's own OAuth authorization server (AUTH-38).",
7
7
  "bin": {
8
8
  "roamer-device-auth": "./roamer-device-auth.mjs"
9
9
  },
@@ -2,17 +2,19 @@
2
2
  // Roamer MCP — server-mediated device authorization flow (AUTH-25), used only for
3
3
  // Safari-default clients since AUTH-14's 2026-08-25 revival. Non-Safari clients (and any
4
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
5
+ // loopback-redirect flow against RoamerMcp's own OAuth authorization server — see
6
6
  // getValidTokensLoopback() below — removing the device-code confirmation screen AUTH-25's
7
7
  // prior unified design imposed on every client, Safari or not.
8
8
  //
9
9
  // Why Safari still needs device-code: Safari's HTTPS-Only Mode unconditionally blocks the
10
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.
11
+ // 2026-08-20), so RoamerMcp's server implements the RFC 8628 shape itself for that case.
12
+ // AUTH-38: as of 2026-08-27 the actual login step this brokers is against Entra directly
13
+ // (RoamerMcp's server acting as an OAuth client of its own AS, then handing this process a
14
+ // RoamerMcp-signed token pair) previously it brokered against Cloudflare Access Managed
15
+ // OAuth server-to-server instead, back when Cloudflare was the token issuer for every
16
+ // RoamerMcp client, not just this one. detectDefaultBrowser() below decides which path
17
+ // (this one or the loopback one) a given machine takes.
16
18
  //
17
19
  // AUTH-24: this is the publishable source for the @mathismeadows/roamer-device-auth npm
18
20
  // package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
@@ -30,6 +32,7 @@ import {
30
32
  refreshAuthorization,
31
33
  } from "@modelcontextprotocol/sdk/client/auth.js";
32
34
  import { readFile, writeFile, mkdir } from "node:fs/promises";
35
+ import { realpathSync } from "node:fs";
33
36
  import { homedir } from "node:os";
34
37
  import { join } from "node:path";
35
38
  import { setTimeout as sleep } from "node:timers/promises";
@@ -110,41 +113,50 @@ function clearCachedTokens() {
110
113
  return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
111
114
  }
112
115
 
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() {
116
+ // AUTH-14/AUTH-38: loopback-flow caches, stamped with the authorization server URL they were
117
+ // obtained against. Not just a version stamp (like CACHE_VERSION above) a real issuer
118
+ // comparison, so ANY future AS migration self-invalidates these automatically, not just this
119
+ // one. Confirmed live 2026-08-27 this gap is real, not theoretical: AUTH-35 retargeted RoamerMcp's
120
+ // discovery documents from Cloudflare Access Managed OAuth to RoamerMcp's own AS, and a client_id
121
+ // or token cached from before that change would otherwise be silently carried forward and
122
+ // presented to a completely different issuer with no invalidation at all.
123
+ async function readLoopbackTokens(issuerUrl) {
117
124
  try {
118
125
  const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
126
+ if (tokens?.issuerUrl !== issuerUrl) return null;
119
127
  return tokens?.access_token ? tokens : null;
120
128
  } catch {
121
129
  return null;
122
130
  }
123
131
  }
124
132
 
125
- async function writeLoopbackTokens(tokens) {
133
+ async function writeLoopbackTokens(tokens, issuerUrl) {
126
134
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
127
- await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
135
+ const stamped = { ...tokens, issuerUrl };
136
+ await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
128
137
  }
129
138
 
130
139
  function clearLoopbackTokens() {
131
140
  return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
132
141
  }
133
142
 
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() {
143
+ // DCR'd client registration is reused across runs — the authorization server's
144
+ // registration_endpoint has no reason to be hit on every single sign-in, only the first one (or
145
+ // after a reset, or after an issuer change per this function's own issuerUrl check above).
146
+ async function readLoopbackClientInfo(issuerUrl) {
137
147
  try {
138
148
  const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
149
+ if (info?.issuerUrl !== issuerUrl) return null;
139
150
  return info?.client_id ? info : null;
140
151
  } catch {
141
152
  return null;
142
153
  }
143
154
  }
144
155
 
145
- async function writeLoopbackClientInfo(info) {
156
+ async function writeLoopbackClientInfo(info, issuerUrl) {
146
157
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
147
- await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(info, null, 2), { mode: 0o600 });
158
+ const stamped = { ...info, issuerUrl };
159
+ await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
148
160
  }
149
161
 
150
162
  // AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
@@ -175,7 +187,7 @@ async function detectDefaultBrowser() {
175
187
 
176
188
  // Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
177
189
  // see a stdio handshake within its own connect timeout — and the interactive device flow
178
- // (dialog -> browser -> Cloudflare's consent screen -> poll) routinely takes longer than a
190
+ // (dialog -> browser -> Entra's consent screen -> poll) routinely takes longer than a
179
191
  // human can click through inside a short timeout. Without persisting the in-progress flow,
180
192
  // every respawn called /oauth/device/start again, which mints a BRAND NEW device_code/
181
193
  // user_code — the user would see a different code every single retry, forever, with no
@@ -340,8 +352,8 @@ async function doGetValidTokens(forceRefresh) {
340
352
  }
341
353
  }
342
354
 
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
355
+ // AUTH-14: fixed, well-known port for the loopback callback — the authorization server's DCR'd
356
+ // client registration is persisted and reused across every future sign-in (see
345
357
  // readLoopbackClientInfo), so the redirect_uri baked into that registration has to stay
346
358
  // stable across runs. A fixed port sidesteps re-registering on every single interactive
347
359
  // sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
@@ -412,21 +424,26 @@ function getValidTokensLoopback(forceRefresh = false) {
412
424
  }
413
425
 
414
426
  async function doGetValidTokensLoopback(forceRefresh) {
415
- let tokens = forceRefresh ? null : await readLoopbackTokens();
427
+ // AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
428
+ // cached-but-not-yet-expired token from a prior authorization server would otherwise be
429
+ // returned early below without ever learning the issuer changed underneath it.
430
+ const serverInfo = await discoverLoopbackServerInfo();
431
+ const issuerUrl = serverInfo.authorizationServerUrl.toString();
432
+
433
+ let tokens = forceRefresh ? null : await readLoopbackTokens(issuerUrl);
416
434
 
417
435
  if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
418
436
  return tokens;
419
437
  }
420
438
 
421
- const serverInfo = await discoverLoopbackServerInfo();
422
- let clientInformation = await readLoopbackClientInfo();
439
+ let clientInformation = await readLoopbackClientInfo(issuerUrl);
423
440
 
424
441
  if (!forceRefresh && tokens?.refresh_token && clientInformation) {
425
442
  try {
426
443
  log("Refreshing cached token...");
427
444
  const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
428
445
  tokens = { ...fresh, obtained_at: Date.now() };
429
- await writeLoopbackTokens(tokens);
446
+ await writeLoopbackTokens(tokens, issuerUrl);
430
447
  return tokens;
431
448
  } catch (err) {
432
449
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
@@ -436,7 +453,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
436
453
  if (forceRefresh) await clearLoopbackTokens();
437
454
 
438
455
  if (!clientInformation) {
439
- log("Registering as a new OAuth client with Cloudflare...");
456
+ log("Registering as a new OAuth client...");
440
457
  clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
441
458
  metadata: serverInfo.authorizationServerMetadata,
442
459
  clientMetadata: {
@@ -447,21 +464,20 @@ async function doGetValidTokensLoopback(forceRefresh) {
447
464
  token_endpoint_auth_method: "none",
448
465
  },
449
466
  });
450
- await writeLoopbackClientInfo(clientInformation);
467
+ await writeLoopbackClientInfo(clientInformation, issuerUrl);
451
468
  }
452
469
 
453
470
  log("Starting sign-in...");
471
+ // AUTH-38: no `resource` parameter — that was a Cloudflare Access Managed OAuth-specific
472
+ // requirement (RFC 8707; it rejected the request without one). RoamerMcp's own /oauth/authorize
473
+ // doesn't bind or read a resource parameter at all, so sending one now would be dead weight,
474
+ // not a compatibility need — same reasoning as OAuthAuthorizationServerService's own Entra-
475
+ // facing hop never sending one (see that class's doc comment for the Entra-side reason, a
476
+ // different one: including it there triggers AADSTS9010010).
454
477
  const { authorizationUrl, codeVerifier } = await startAuthorization(serverInfo.authorizationServerUrl, {
455
478
  metadata: serverInfo.authorizationServerMetadata,
456
479
  clientInformation,
457
480
  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
481
  });
466
482
 
467
483
  const server = createServer();
@@ -501,7 +517,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
501
517
  redirectUri: LOOPBACK_REDIRECT_URI,
502
518
  });
503
519
  tokens = { ...fresh, obtained_at: Date.now() };
504
- await writeLoopbackTokens(tokens);
520
+ await writeLoopbackTokens(tokens, issuerUrl);
505
521
  log("Sign-in complete.");
506
522
  return tokens;
507
523
  }
@@ -650,7 +666,10 @@ async function main() {
650
666
 
651
667
  // Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
652
668
  // must not trigger a live device-auth flow and stdio takeover as a side effect.
653
- const isMainModule = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
669
+ // AUTH-29: npx always launches this via the node_modules/.bin symlink, so argv[1] must be
670
+ // realpath-resolved before comparing — import.meta.url is already realpath-resolved by Node.
671
+ const isMainModule =
672
+ process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
654
673
  if (isMainModule) {
655
674
  main().catch((err) => {
656
675
  log(`Fatal error: ${err.stack ?? err.message}`);
@@ -658,4 +677,12 @@ if (isMainModule) {
658
677
  });
659
678
  }
660
679
 
661
- export { respondWithSignInError, forwardLine, detectDefaultBrowser };
680
+ export {
681
+ respondWithSignInError,
682
+ forwardLine,
683
+ detectDefaultBrowser,
684
+ readLoopbackTokens,
685
+ writeLoopbackTokens,
686
+ readLoopbackClientInfo,
687
+ writeLoopbackClientInfo,
688
+ };