@mathismeadows/roamer-device-auth 1.2.1 → 1.3.1

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,10 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
+ "mcpName": "com.mathismeadows/roamer-mcp",
5
6
  "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.",
7
+ "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
8
  "bin": {
8
9
  "roamer-device-auth": "./roamer-device-auth.mjs"
9
10
  },
@@ -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,
@@ -111,41 +113,50 @@ function clearCachedTokens() {
111
113
  return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
112
114
  }
113
115
 
114
- // AUTH-14: loopback-flow cache a brand-new file, never previously used by any other
115
- // mechanism, so (unlike CACHE_FILE above) there's no prior-format collision risk to guard
116
- // against with a version stamp.
117
- 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) {
118
124
  try {
119
125
  const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
126
+ if (tokens?.issuerUrl !== issuerUrl) return null;
120
127
  return tokens?.access_token ? tokens : null;
121
128
  } catch {
122
129
  return null;
123
130
  }
124
131
  }
125
132
 
126
- async function writeLoopbackTokens(tokens) {
133
+ async function writeLoopbackTokens(tokens, issuerUrl) {
127
134
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
128
- 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 });
129
137
  }
130
138
 
131
139
  function clearLoopbackTokens() {
132
140
  return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
133
141
  }
134
142
 
135
- // DCR'd client registration is reused across runs — Cloudflare's registration_endpoint has
136
- // no reason to be hit on every single sign-in, only the first one (or after a reset).
137
- 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) {
138
147
  try {
139
148
  const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
149
+ if (info?.issuerUrl !== issuerUrl) return null;
140
150
  return info?.client_id ? info : null;
141
151
  } catch {
142
152
  return null;
143
153
  }
144
154
  }
145
155
 
146
- async function writeLoopbackClientInfo(info) {
156
+ async function writeLoopbackClientInfo(info, issuerUrl) {
147
157
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
148
- 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 });
149
160
  }
150
161
 
151
162
  // AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
@@ -176,7 +187,7 @@ async function detectDefaultBrowser() {
176
187
 
177
188
  // Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
178
189
  // see a stdio handshake within its own connect timeout — and the interactive device flow
179
- // (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
180
191
  // human can click through inside a short timeout. Without persisting the in-progress flow,
181
192
  // every respawn called /oauth/device/start again, which mints a BRAND NEW device_code/
182
193
  // user_code — the user would see a different code every single retry, forever, with no
@@ -341,8 +352,8 @@ async function doGetValidTokens(forceRefresh) {
341
352
  }
342
353
  }
343
354
 
344
- // AUTH-14: fixed, well-known port for the loopback callback — Cloudflare's DCR'd client
345
- // 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
346
357
  // readLoopbackClientInfo), so the redirect_uri baked into that registration has to stay
347
358
  // stable across runs. A fixed port sidesteps re-registering on every single interactive
348
359
  // sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
@@ -413,21 +424,26 @@ function getValidTokensLoopback(forceRefresh = false) {
413
424
  }
414
425
 
415
426
  async function doGetValidTokensLoopback(forceRefresh) {
416
- 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);
417
434
 
418
435
  if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
419
436
  return tokens;
420
437
  }
421
438
 
422
- const serverInfo = await discoverLoopbackServerInfo();
423
- let clientInformation = await readLoopbackClientInfo();
439
+ let clientInformation = await readLoopbackClientInfo(issuerUrl);
424
440
 
425
441
  if (!forceRefresh && tokens?.refresh_token && clientInformation) {
426
442
  try {
427
443
  log("Refreshing cached token...");
428
444
  const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
429
445
  tokens = { ...fresh, obtained_at: Date.now() };
430
- await writeLoopbackTokens(tokens);
446
+ await writeLoopbackTokens(tokens, issuerUrl);
431
447
  return tokens;
432
448
  } catch (err) {
433
449
  log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
@@ -437,7 +453,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
437
453
  if (forceRefresh) await clearLoopbackTokens();
438
454
 
439
455
  if (!clientInformation) {
440
- log("Registering as a new OAuth client with Cloudflare...");
456
+ log("Registering as a new OAuth client...");
441
457
  clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
442
458
  metadata: serverInfo.authorizationServerMetadata,
443
459
  clientMetadata: {
@@ -448,21 +464,20 @@ async function doGetValidTokensLoopback(forceRefresh) {
448
464
  token_endpoint_auth_method: "none",
449
465
  },
450
466
  });
451
- await writeLoopbackClientInfo(clientInformation);
467
+ await writeLoopbackClientInfo(clientInformation, issuerUrl);
452
468
  }
453
469
 
454
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).
455
477
  const { authorizationUrl, codeVerifier } = await startAuthorization(serverInfo.authorizationServerUrl, {
456
478
  metadata: serverInfo.authorizationServerMetadata,
457
479
  clientInformation,
458
480
  redirectUrl: LOOPBACK_REDIRECT_URI,
459
- // RFC 8707: Cloudflare's authorization endpoint rejects the request without this
460
- // (invalid_target / "No resource parameter found") — the exact same requirement
461
- // DeviceFlowService.cs already hit and fixed server-side for the device-code path
462
- // (confirmed live 2026-08-21) and just reconfirmed live here 2026-08-26. Only the
463
- // authorization step needs it; DeviceFlowService.cs's token/refresh calls don't
464
- // reference it at all, so exchangeAuthorization/refreshAuthorization below don't either.
465
- resource: new URL(ROAMER_MCP_URL),
466
481
  });
467
482
 
468
483
  const server = createServer();
@@ -502,7 +517,7 @@ async function doGetValidTokensLoopback(forceRefresh) {
502
517
  redirectUri: LOOPBACK_REDIRECT_URI,
503
518
  });
504
519
  tokens = { ...fresh, obtained_at: Date.now() };
505
- await writeLoopbackTokens(tokens);
520
+ await writeLoopbackTokens(tokens, issuerUrl);
506
521
  log("Sign-in complete.");
507
522
  return tokens;
508
523
  }
@@ -662,4 +677,12 @@ if (isMainModule) {
662
677
  });
663
678
  }
664
679
 
665
- export { respondWithSignInError, forwardLine, detectDefaultBrowser };
680
+ export {
681
+ respondWithSignInError,
682
+ forwardLine,
683
+ detectDefaultBrowser,
684
+ readLoopbackTokens,
685
+ writeLoopbackTokens,
686
+ readLoopbackClientInfo,
687
+ writeLoopbackClientInfo,
688
+ };