@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.
- package/package.json +4 -3
- 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.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "
|
|
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
|
}
|
package/roamer-device-auth.mjs
CHANGED
|
@@ -1,37 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Roamer MCP —
|
|
2
|
+
// Roamer MCP — server-mediated device authorization flow (AUTH-25).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// (
|
|
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
14
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
73
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
const
|
|
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/
|
|
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(`
|
|
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
|
|
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(`${
|
|
126
|
+
const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/poll`, {
|
|
113
127
|
method: "POST",
|
|
114
|
-
headers: { "Content-Type": "application/
|
|
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
|
|
142
|
+
throw new Error(`Device flow login failed: ${data.error}`);
|
|
129
143
|
}
|
|
130
144
|
}
|
|
131
145
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 flow — worst 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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
|
175
|
+
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
175
176
|
}
|
|
176
177
|
}
|
|
177
178
|
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
182
|
-
//
|
|
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.
|
|
189
|
+
await open(device.verification_uri_complete);
|
|
188
190
|
} catch {
|
|
189
|
-
// Best-effort only — the dialog
|
|
191
|
+
// Best-effort only — the dialog and QR code below already carry the URL and code.
|
|
190
192
|
}
|
|
191
|
-
|
|
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("
|
|
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) =>
|
|
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
|
-
//
|
|
215
|
-
//
|
|
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
|
-
|
|
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
|
}
|