@mathismeadows/roamer-device-auth 1.0.1 → 1.1.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.
Files changed (2) hide show
  1. package/package.json +4 -3
  2. package/roamer-device-auth.mjs +130 -105
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "OAuth 2.0 Device Authorization Grant auth + stdio<->HTTP MCP proxy for Roamer MCP, used by roamer-bridge.sh when the OS's default browser is Safari (AUTH-14).",
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.",
7
7
  "bin": {
8
8
  "roamer-device-auth": "./roamer-device-auth.mjs"
9
9
  },
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@modelcontextprotocol/sdk": "^1.29.0",
19
- "open": "^11.0.0"
19
+ "open": "^11.0.0",
20
+ "qrcode-terminal": "^0.12.0"
20
21
  }
21
22
  }
@@ -1,37 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // Roamer MCP — device-code auth path (AUTH-14).
2
+ // Roamer MCP — server-mediated device authorization flow (AUTH-25).
3
3
  //
4
- // Used instead of roamer-bridge.sh's normal mcp-remote/loopback-redirect flow
5
- // when the OS's default browser is Safari. Safari's HTTPS-Only Mode blocks
6
- // ANY plain http:// top-level navigation, so a loopback redirect callback
7
- // (http://127.0.0.1:<port>/oauth/callback) never works there no matter what
8
- // the redirect URI looks like see AUTH-14 and the roamer-bridge.sh comments.
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
14
  //
10
- // The OAuth 2.0 Device Authorization Grant sidesteps the problem entirely:
11
- // there is no redirect URI and no local listener at all. The user visits a
12
- // real https://microsoft.com page and types a short code; this process polls
13
- // Entra's token endpoint until that completes. Independent of mcp-remote —
14
- // this file owns its own token cache and its own stdio<->HTTP MCP proxying
15
- // (via the official @modelcontextprotocol/sdk), rather than assuming anything
16
- // about mcp-remote's on-disk format.
15
+ // AUTH-24: this is the publishable source for the @mathismeadows/roamer-device-auth npm
16
+ // package roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
17
+ // version-pinned package via npx rather than running this file in place.
17
18
  //
18
- // stdout is reserved for the JSON-RPC protocol channel; all logging goes to
19
- // stderr, matching the convention in roamer-bridge.sh / mcp-remote.
20
- //
21
- // The verification URL + user code are also shown via a native macOS dialog
22
- // (osascript), not just logged to stderr: this script normally runs as an MCP
23
- // host's spawned stdio subprocess, and hosts are not obligated to surface a
24
- // child process's stderr anywhere visible — confirmed by hitting exactly that
25
- // with a stderr-only first version of this file. Entra's device-code response
26
- // has no verification_uri_complete (pre-filled-code URL) field, so the code
27
- // can only be conveyed as text; the dialog is what makes that text guaranteed
28
- // visible, the same way the redirect flow's login prompt is inherently
29
- // visible by virtue of a real browser window opening.
30
- //
31
- // AUTH-24: this is the publishable source for the @mathismeadows/roamer-device-auth
32
- // npm package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the
33
- // published, version-pinned package via npx rather than running this file
34
- // in place, so the public plugin repo doesn't have to carry this source.
19
+ // stdout is reserved for the JSON-RPC protocol channel; all logging goes to stderr.
35
20
 
36
21
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
37
22
  import { readFile, writeFile, mkdir } from "node:fs/promises";
@@ -40,43 +25,56 @@ import { join } from "node:path";
40
25
  import { setTimeout as sleep } from "node:timers/promises";
41
26
  import { execFile } from "node:child_process";
42
27
  import { promisify } from "node:util";
28
+ import qrcode from "qrcode-terminal";
43
29
 
44
30
  const execFileAsync = promisify(execFile);
45
31
 
46
32
  const ROAMER_MCP_URL = process.env.ROAMER_MCP_URL ?? "https://roamer-mcp.mathismeadows.com/mcp";
47
- const ROAMER_MCP_CLIENT_ID = process.env.ROAMER_MCP_CLIENT_ID ?? "a55708ff-a990-4a06-afa8-d2fc86980b4e";
48
- const ROAMER_MCP_TENANT_ID = process.env.ROAMER_MCP_TENANT_ID ?? "6099dc20-e8a0-4925-b68d-b9d267e01cff";
49
- const SCOPE = "https://roamer-mcp.mathismeadows.com/mcp/mcp.access offline_access";
33
+ const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
50
34
 
51
- const AUTHORITY = `https://login.microsoftonline.com/${ROAMER_MCP_TENANT_ID}/oauth2/v2.0`;
52
35
  const CACHE_DIR = join(homedir(), ".mcp-auth-device");
53
36
  const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
54
37
 
38
+ // Bumped whenever the cached shape changes meaningfully. A cache written by a prior
39
+ // mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
40
+ // (access_token, expires_in, obtained_at) with this format, so a plain presence check isn't
41
+ // enough to trust it — confirmed live 2026-08-21: a stale Entra JWT in this exact cache file
42
+ // was blindly reused and rejected by Cloudflare with invalid_token, with no way to detect or
43
+ // recover short of manually deleting the file. This version stamp is the fix.
44
+ const CACHE_VERSION = 1;
45
+
55
46
  function log(message) {
56
47
  process.stderr.write(`[roamer-device-auth] ${message}\n`);
57
48
  }
58
49
 
59
- // stderr text alone is not a reliable way to reach the user: this process is
60
- // typically spawned as an MCP host's stdio subprocess, and hosts are not
61
- // obligated to surface a child process's stderr anywhere a human will see it
62
- // (confirmed — a host running this exact script did not). A native dialog is
63
- // an OS-level surface, guaranteed visible independent of what the host does
64
- // with stderr, mirroring how the redirect flow's visibility never depended on
65
- // stderr either (a real browser window opening is its own visible side effect).
50
+ // StreamableHTTPClientTransport surfaces the upstream response body in the error message
51
+ // (confirmed live: "Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_token",...}")
52
+ // pattern-match on that rather than a status code, since the SDK doesn't expose one directly.
53
+ function isAuthError(err) {
54
+ return /invalid_token|unauthorized|\b401\b/i.test(err?.message ?? "");
55
+ }
56
+
57
+ // A native dialog is an OS-level surface, guaranteed visible independent of whether the MCP
58
+ // host surfaces this process's stderr anywhere a human will see it (confirmed on the old
59
+ // Entra-direct flow: a stderr-only first version left the user with no way to see the code).
66
60
  async function showDeviceCodeDialog(verificationUri, userCode) {
67
61
  const message = `Sign in to Roamer MCP:\n\n1. Go to ${verificationUri}\n2. Enter code: ${userCode}`;
68
62
  const script = `display dialog "${message.replace(/"/g, '\\"')}" with title "Roamer MCP Sign-In" buttons {"OK"} default button "OK"`;
69
63
  try {
70
64
  await execFileAsync("osascript", ["-e", script]);
71
65
  } catch {
72
- // Best-effort only — the code is still in the fatal-error-free stderr log
73
- // above for anyone who does have visibility into it.
66
+ // Best-effort, macOS-only — the terminal-visible QR code and stderr log below are the
67
+ // fallback for every other OS and for any environment without osascript.
74
68
  }
75
69
  }
76
70
 
77
71
  async function readCachedTokens() {
78
72
  try {
79
- return JSON.parse(await readFile(CACHE_FILE, "utf8"));
73
+ const tokens = JSON.parse(await readFile(CACHE_FILE, "utf8"));
74
+ // A cache from an incompatible prior format must never be trusted just because it
75
+ // happens to have the right field names — see CACHE_VERSION's comment.
76
+ if (tokens?.cacheVersion !== CACHE_VERSION) return null;
77
+ return tokens;
80
78
  } catch {
81
79
  return null;
82
80
  }
@@ -84,35 +82,51 @@ async function readCachedTokens() {
84
82
 
85
83
  async function writeCachedTokens(tokens) {
86
84
  await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
87
- await writeFile(CACHE_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
85
+ const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
86
+ await writeFile(CACHE_FILE, JSON.stringify(stamped, null, 2), { mode: 0o600 });
87
+ }
88
+
89
+ function clearCachedTokens() {
90
+ return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
91
+ }
92
+
93
+ function expiresSoon(tokens) {
94
+ // expires_in is optional in the response shape — an absent value means treat the token as
95
+ // long-lived and rely on a real 401 to trigger re-auth rather than guessing a lifetime.
96
+ if (!tokens.expires_in) return false;
97
+ const obtainedAt = tokens.obtained_at ?? 0;
98
+ const expiresAt = obtainedAt + tokens.expires_in * 1000;
99
+ return Date.now() > expiresAt - 60_000; // refresh a minute early
88
100
  }
89
101
 
90
- async function requestDeviceCode() {
91
- const body = new URLSearchParams({ client_id: ROAMER_MCP_CLIENT_ID, scope: SCOPE });
92
- const response = await fetch(`${AUTHORITY}/devicecode`, {
102
+ async function refreshTokens(refreshToken) {
103
+ const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/refresh`, {
93
104
  method: "POST",
94
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
95
- body,
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify({ refresh_token: refreshToken }),
96
107
  });
97
108
  if (!response.ok) {
98
- throw new Error(`Device code request failed: ${response.status} ${await response.text()}`);
109
+ throw new Error(`Token refresh failed: ${response.status} ${await response.text()}`);
110
+ }
111
+ return response.json();
112
+ }
113
+
114
+ async function startDeviceFlow() {
115
+ const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/start`, { method: "POST" });
116
+ if (!response.ok) {
117
+ throw new Error(`Device flow start failed: ${response.status} ${await response.text()}`);
99
118
  }
100
119
  return response.json();
101
120
  }
102
121
 
103
- async function pollForToken(deviceCode, intervalSeconds) {
122
+ async function pollDeviceFlow(deviceCode, intervalSeconds) {
104
123
  let interval = intervalSeconds;
105
- const body = new URLSearchParams({
106
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
107
- device_code: deviceCode,
108
- client_id: ROAMER_MCP_CLIENT_ID,
109
- });
110
124
  for (;;) {
111
125
  await sleep(interval * 1000);
112
- const response = await fetch(`${AUTHORITY}/token`, {
126
+ const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/poll`, {
113
127
  method: "POST",
114
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
115
- body,
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({ device_code: deviceCode }),
116
130
  });
117
131
  const data = await response.json();
118
132
  if (response.ok) {
@@ -125,45 +139,32 @@ async function pollForToken(deviceCode, intervalSeconds) {
125
139
  interval += 5;
126
140
  continue;
127
141
  }
128
- throw new Error(`Device code login failed: ${data.error} — ${data.error_description ?? ""}`);
142
+ throw new Error(`Device flow login failed: ${data.error}`);
129
143
  }
130
144
  }
131
145
 
132
- async function refreshTokens(refreshToken) {
133
- // Deliberately scope-based (not resource-based)see the AADSTS90009 note
134
- // in roamer-bridge.sh for why a resource-indicator refresh fails against
135
- // this app registration.
136
- const body = new URLSearchParams({
137
- grant_type: "refresh_token",
138
- refresh_token: refreshToken,
139
- client_id: ROAMER_MCP_CLIENT_ID,
140
- scope: SCOPE,
141
- });
142
- const response = await fetch(`${AUTHORITY}/token`, {
143
- method: "POST",
144
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
145
- body,
146
- });
147
- if (!response.ok) {
148
- throw new Error(`Token refresh failed: ${response.status} ${await response.text()}`);
149
- }
150
- return response.json();
151
- }
146
+ // Concurrent stdin messages arriving during a refresh/re-auth window must not each kick off
147
+ // their own competing flowworst case that means multiple device-code dialogs popping up
148
+ // at once, or two refreshes racing on a rotating refresh_token where the loser's retry then
149
+ // forces an unnecessary full sign-in. All concurrent callers await the same in-flight op.
150
+ let inFlightTokens = null;
152
151
 
153
- function expiresSoon(tokens) {
154
- const obtainedAt = tokens.obtained_at ?? 0;
155
- const expiresAt = obtainedAt + (tokens.expires_in ?? 0) * 1000;
156
- return Date.now() > expiresAt - 60_000; // refresh a minute early
152
+ function getValidTokens(forceRefresh = false) {
153
+ if (inFlightTokens) return inFlightTokens;
154
+ inFlightTokens = doGetValidTokens(forceRefresh).finally(() => {
155
+ inFlightTokens = null;
156
+ });
157
+ return inFlightTokens;
157
158
  }
158
159
 
159
- async function getValidTokens() {
160
- let tokens = await readCachedTokens();
160
+ async function doGetValidTokens(forceRefresh) {
161
+ let tokens = forceRefresh ? null : await readCachedTokens();
161
162
 
162
- if (tokens && !expiresSoon(tokens)) {
163
+ if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
163
164
  return tokens;
164
165
  }
165
166
 
166
- if (tokens?.refresh_token) {
167
+ if (!forceRefresh && tokens?.refresh_token) {
167
168
  try {
168
169
  log("Refreshing cached token...");
169
170
  const fresh = await refreshTokens(tokens.refresh_token);
@@ -171,27 +172,35 @@ async function getValidTokens() {
171
172
  await writeCachedTokens(tokens);
172
173
  return tokens;
173
174
  } catch (err) {
174
- log(`Refresh failed (${err.message}), falling back to device-code login.`);
175
+ log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
175
176
  }
176
177
  }
177
178
 
178
- log("Starting device-code login...");
179
- const device = await requestDeviceCode();
179
+ if (forceRefresh) await clearCachedTokens();
180
+
181
+ log("Starting sign-in...");
182
+ const device = await startDeviceFlow();
180
183
  log(`Go to ${device.verification_uri} and enter code: ${device.user_code}`);
181
- // Fire-and-forget: the dialog is how the human actually sees this (see
182
- // showDeviceCodeDialog above); polling below must not wait on it being
183
- // dismissed.
184
+ // Fire-and-forget: the dialog is how the human actually sees this on macOS; polling below
185
+ // must not wait on it being dismissed.
184
186
  showDeviceCodeDialog(device.verification_uri, device.user_code);
185
187
  try {
186
188
  const { default: open } = await import("open");
187
- await open(device.verification_uri);
189
+ await open(device.verification_uri_complete);
188
190
  } catch {
189
- // Best-effort only — the dialog above already carries the URL and code.
191
+ // Best-effort only — the dialog and QR code below already carry the URL and code.
190
192
  }
191
- const fresh = await pollForToken(device.device_code, device.interval ?? 5);
193
+ // Terminal-visible for any client where a human sees this process's stderr/log output
194
+ // (Claude Code CLI, VS Code's integrated terminal) — QR codes are a real usability win
195
+ // 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
+ });
199
+
200
+ const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
192
201
  tokens = { ...fresh, obtained_at: Date.now() };
193
202
  await writeCachedTokens(tokens);
194
- log("Login complete.");
203
+ log("Sign-in complete.");
195
204
  return tokens;
196
205
  }
197
206
 
@@ -206,14 +215,19 @@ async function main() {
206
215
  },
207
216
  });
208
217
 
209
- transport.onerror = (err) => log(`Transport error: ${err.message}`);
218
+ transport.onerror = (err) => {
219
+ log(`Transport error: ${err.message}`);
220
+ // Same reasoning as the send-path retry below: an auth error means whatever's cached is
221
+ // known-bad, so drop it now rather than let the next proactive expiresSoon() check
222
+ // (which only reasons about calendar time) keep handing it out.
223
+ if (isAuthError(err)) clearCachedTokens();
224
+ };
210
225
  await transport.start();
211
226
  log("Connected to remote server using StreamableHTTPClientTransport.");
212
227
 
213
- // stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC
214
- // message; transport.send() delivers it, and transport.onmessage delivers
215
- // whatever comes back (including server-initiated messages over the
216
- // SSE half of the streamable-HTTP transport).
228
+ // stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
229
+ // transport.send() delivers it, and transport.onmessage delivers whatever comes back
230
+ // (including server-initiated messages over the SSE half of the streamable-HTTP transport).
217
231
  transport.onmessage = (message) => {
218
232
  process.stdout.write(`${JSON.stringify(message)}\n`);
219
233
  };
@@ -232,7 +246,18 @@ async function main() {
232
246
  if (expiresSoon(tokens)) {
233
247
  tokens = await getValidTokens();
234
248
  }
235
- await transport.send(JSON.parse(line));
249
+ try {
250
+ await transport.send(JSON.parse(line));
251
+ } catch (err) {
252
+ // Reactive invalidation: proactive expiry math can't catch everything (server-side
253
+ // revocation, clock skew, or an incompatible cache — see CACHE_VERSION). A real
254
+ // auth failure from the server is the ground truth; when we see one, invalidate
255
+ // whatever we're holding, force a genuinely fresh token, and retry once.
256
+ if (!isAuthError(err)) throw err;
257
+ log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
258
+ tokens = await getValidTokens(true);
259
+ await transport.send(JSON.parse(line));
260
+ }
236
261
  } catch (err) {
237
262
  log(`Send failed: ${err.message}`);
238
263
  }