@mathismeadows/roamer-device-auth 1.0.1 → 1.1.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.0.1",
3
+ "version": "1.1.0",
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,15 +25,13 @@ 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
 
@@ -56,21 +39,17 @@ function log(message) {
56
39
  process.stderr.write(`[roamer-device-auth] ${message}\n`);
57
40
  }
58
41
 
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).
42
+ // A native dialog is an OS-level surface, guaranteed visible independent of whether the MCP
43
+ // host surfaces this process's stderr anywhere a human will see it (confirmed on the old
44
+ // Entra-direct flow: a stderr-only first version left the user with no way to see the code).
66
45
  async function showDeviceCodeDialog(verificationUri, userCode) {
67
46
  const message = `Sign in to Roamer MCP:\n\n1. Go to ${verificationUri}\n2. Enter code: ${userCode}`;
68
47
  const script = `display dialog "${message.replace(/"/g, '\\"')}" with title "Roamer MCP Sign-In" buttons {"OK"} default button "OK"`;
69
48
  try {
70
49
  await execFileAsync("osascript", ["-e", script]);
71
50
  } 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.
51
+ // Best-effort, macOS-only — the terminal-visible QR code and stderr log below are the
52
+ // fallback for every other OS and for any environment without osascript.
74
53
  }
75
54
  }
76
55
 
@@ -87,32 +66,43 @@ async function writeCachedTokens(tokens) {
87
66
  await writeFile(CACHE_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
88
67
  }
89
68
 
90
- async function requestDeviceCode() {
91
- const body = new URLSearchParams({ client_id: ROAMER_MCP_CLIENT_ID, scope: SCOPE });
92
- const response = await fetch(`${AUTHORITY}/devicecode`, {
69
+ function expiresSoon(tokens) {
70
+ // expires_in is optional in the response shape an absent value means treat the token as
71
+ // long-lived and rely on a real 401 to trigger re-auth rather than guessing a lifetime.
72
+ if (!tokens.expires_in) return false;
73
+ const obtainedAt = tokens.obtained_at ?? 0;
74
+ const expiresAt = obtainedAt + tokens.expires_in * 1000;
75
+ return Date.now() > expiresAt - 60_000; // refresh a minute early
76
+ }
77
+
78
+ async function refreshTokens(refreshToken) {
79
+ const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/refresh`, {
93
80
  method: "POST",
94
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
95
- body,
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({ refresh_token: refreshToken }),
96
83
  });
97
84
  if (!response.ok) {
98
- throw new Error(`Device code request failed: ${response.status} ${await response.text()}`);
85
+ throw new Error(`Token refresh failed: ${response.status} ${await response.text()}`);
86
+ }
87
+ return response.json();
88
+ }
89
+
90
+ async function startDeviceFlow() {
91
+ const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/start`, { method: "POST" });
92
+ if (!response.ok) {
93
+ throw new Error(`Device flow start failed: ${response.status} ${await response.text()}`);
99
94
  }
100
95
  return response.json();
101
96
  }
102
97
 
103
- async function pollForToken(deviceCode, intervalSeconds) {
98
+ async function pollDeviceFlow(deviceCode, intervalSeconds) {
104
99
  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
100
  for (;;) {
111
101
  await sleep(interval * 1000);
112
- const response = await fetch(`${AUTHORITY}/token`, {
102
+ const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/poll`, {
113
103
  method: "POST",
114
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
115
- body,
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ device_code: deviceCode }),
116
106
  });
117
107
  const data = await response.json();
118
108
  if (response.ok) {
@@ -125,41 +115,14 @@ async function pollForToken(deviceCode, intervalSeconds) {
125
115
  interval += 5;
126
116
  continue;
127
117
  }
128
- throw new Error(`Device code login failed: ${data.error} — ${data.error_description ?? ""}`);
129
- }
130
- }
131
-
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()}`);
118
+ throw new Error(`Device flow login failed: ${data.error}`);
149
119
  }
150
- return response.json();
151
- }
152
-
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
157
120
  }
158
121
 
159
122
  async function getValidTokens() {
160
123
  let tokens = await readCachedTokens();
161
124
 
162
- if (tokens && !expiresSoon(tokens)) {
125
+ if (tokens?.access_token && !expiresSoon(tokens)) {
163
126
  return tokens;
164
127
  }
165
128
 
@@ -171,27 +134,33 @@ async function getValidTokens() {
171
134
  await writeCachedTokens(tokens);
172
135
  return tokens;
173
136
  } catch (err) {
174
- log(`Refresh failed (${err.message}), falling back to device-code login.`);
137
+ log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
175
138
  }
176
139
  }
177
140
 
178
- log("Starting device-code login...");
179
- const device = await requestDeviceCode();
141
+ log("Starting sign-in...");
142
+ const device = await startDeviceFlow();
180
143
  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.
144
+ // Fire-and-forget: the dialog is how the human actually sees this on macOS; polling below
145
+ // must not wait on it being dismissed.
184
146
  showDeviceCodeDialog(device.verification_uri, device.user_code);
185
147
  try {
186
148
  const { default: open } = await import("open");
187
- await open(device.verification_uri);
149
+ await open(device.verification_uri_complete);
188
150
  } catch {
189
- // Best-effort only — the dialog above already carries the URL and code.
151
+ // Best-effort only — the dialog and QR code below already carry the URL and code.
190
152
  }
191
- const fresh = await pollForToken(device.device_code, device.interval ?? 5);
153
+ // Terminal-visible for any client where a human sees this process's stderr/log output
154
+ // (Claude Code CLI, VS Code's integrated terminal) — QR codes are a real usability win
155
+ // for CLI auth over typing an 8-character code by hand.
156
+ qrcode.generate(device.verification_uri_complete, { small: true }, (qr) => {
157
+ process.stderr.write(`${qr}\n`);
158
+ });
159
+
160
+ const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
192
161
  tokens = { ...fresh, obtained_at: Date.now() };
193
162
  await writeCachedTokens(tokens);
194
- log("Login complete.");
163
+ log("Sign-in complete.");
195
164
  return tokens;
196
165
  }
197
166
 
@@ -210,10 +179,9 @@ async function main() {
210
179
  await transport.start();
211
180
  log("Connected to remote server using StreamableHTTPClientTransport.");
212
181
 
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).
182
+ // stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
183
+ // transport.send() delivers it, and transport.onmessage delivers whatever comes back
184
+ // (including server-initiated messages over the SSE half of the streamable-HTTP transport).
217
185
  transport.onmessage = (message) => {
218
186
  process.stdout.write(`${JSON.stringify(message)}\n`);
219
187
  };