@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 +4 -3
- package/roamer-device-auth.mjs +69 -101
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathismeadows/roamer-device-auth",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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,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
|
|
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
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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
|
|
73
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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/
|
|
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(`
|
|
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
|
|
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(`${
|
|
102
|
+
const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/poll`, {
|
|
113
103
|
method: "POST",
|
|
114
|
-
headers: { "Content-Type": "application/
|
|
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
|
|
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
|
|
137
|
+
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
175
138
|
}
|
|
176
139
|
}
|
|
177
140
|
|
|
178
|
-
log("Starting
|
|
179
|
-
const device = await
|
|
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
|
|
182
|
-
//
|
|
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.
|
|
149
|
+
await open(device.verification_uri_complete);
|
|
188
150
|
} catch {
|
|
189
|
-
// Best-effort only — the dialog
|
|
151
|
+
// Best-effort only — the dialog and QR code below already carry the URL and code.
|
|
190
152
|
}
|
|
191
|
-
|
|
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("
|
|
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
|
-
//
|
|
215
|
-
//
|
|
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
|
};
|