@mathismeadows/roamer-device-auth 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@mathismeadows/roamer-device-auth",
3
+ "version": "1.0.0",
4
+ "private": false,
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).",
7
+ "bin": {
8
+ "roamer-device-auth": "./roamer-device-auth.mjs"
9
+ },
10
+ "main": "./roamer-device-auth.mjs",
11
+ "files": [
12
+ "roamer-device-auth.mjs"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.29.0",
19
+ "open": "^11.0.0"
20
+ }
21
+ }
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ // Roamer MCP — device-code auth path (AUTH-14).
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.
9
+ //
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.
17
+ //
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.
35
+
36
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
37
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
38
+ import { homedir } from "node:os";
39
+ import { join } from "node:path";
40
+ import { setTimeout as sleep } from "node:timers/promises";
41
+ import { execFile } from "node:child_process";
42
+ import { promisify } from "node:util";
43
+
44
+ const execFileAsync = promisify(execFile);
45
+
46
+ 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";
50
+
51
+ const AUTHORITY = `https://login.microsoftonline.com/${ROAMER_MCP_TENANT_ID}/oauth2/v2.0`;
52
+ const CACHE_DIR = join(homedir(), ".mcp-auth-device");
53
+ const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
54
+
55
+ function log(message) {
56
+ process.stderr.write(`[roamer-device-auth] ${message}\n`);
57
+ }
58
+
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).
66
+ async function showDeviceCodeDialog(verificationUri, userCode) {
67
+ const message = `Sign in to Roamer MCP:\n\n1. Go to ${verificationUri}\n2. Enter code: ${userCode}`;
68
+ const script = `display dialog "${message.replace(/"/g, '\\"')}" with title "Roamer MCP Sign-In" buttons {"OK"} default button "OK"`;
69
+ try {
70
+ await execFileAsync("osascript", ["-e", script]);
71
+ } 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.
74
+ }
75
+ }
76
+
77
+ async function readCachedTokens() {
78
+ try {
79
+ return JSON.parse(await readFile(CACHE_FILE, "utf8"));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ async function writeCachedTokens(tokens) {
86
+ await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
87
+ await writeFile(CACHE_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
88
+ }
89
+
90
+ async function requestDeviceCode() {
91
+ const body = new URLSearchParams({ client_id: ROAMER_MCP_CLIENT_ID, scope: SCOPE });
92
+ const response = await fetch(`${AUTHORITY}/devicecode`, {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
95
+ body,
96
+ });
97
+ if (!response.ok) {
98
+ throw new Error(`Device code request failed: ${response.status} ${await response.text()}`);
99
+ }
100
+ return response.json();
101
+ }
102
+
103
+ async function pollForToken(deviceCode, intervalSeconds) {
104
+ 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
+ for (;;) {
111
+ await sleep(interval * 1000);
112
+ const response = await fetch(`${AUTHORITY}/token`, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
115
+ body,
116
+ });
117
+ const data = await response.json();
118
+ if (response.ok) {
119
+ return data;
120
+ }
121
+ if (data.error === "authorization_pending") {
122
+ continue;
123
+ }
124
+ if (data.error === "slow_down") {
125
+ interval += 5;
126
+ continue;
127
+ }
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()}`);
149
+ }
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
+ }
158
+
159
+ async function getValidTokens() {
160
+ let tokens = await readCachedTokens();
161
+
162
+ if (tokens && !expiresSoon(tokens)) {
163
+ return tokens;
164
+ }
165
+
166
+ if (tokens?.refresh_token) {
167
+ try {
168
+ log("Refreshing cached token...");
169
+ const fresh = await refreshTokens(tokens.refresh_token);
170
+ tokens = { ...fresh, obtained_at: Date.now() };
171
+ await writeCachedTokens(tokens);
172
+ return tokens;
173
+ } catch (err) {
174
+ log(`Refresh failed (${err.message}), falling back to device-code login.`);
175
+ }
176
+ }
177
+
178
+ log("Starting device-code login...");
179
+ const device = await requestDeviceCode();
180
+ 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
+ showDeviceCodeDialog(device.verification_uri, device.user_code);
185
+ try {
186
+ const { default: open } = await import("open");
187
+ await open(device.verification_uri);
188
+ } catch {
189
+ // Best-effort only — the dialog above already carries the URL and code.
190
+ }
191
+ const fresh = await pollForToken(device.device_code, device.interval ?? 5);
192
+ tokens = { ...fresh, obtained_at: Date.now() };
193
+ await writeCachedTokens(tokens);
194
+ log("Login complete.");
195
+ return tokens;
196
+ }
197
+
198
+ async function main() {
199
+ let tokens = await getValidTokens();
200
+
201
+ const transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
202
+ requestInit: {
203
+ get headers() {
204
+ return { Authorization: `Bearer ${tokens.access_token}` };
205
+ },
206
+ },
207
+ });
208
+
209
+ transport.onerror = (err) => log(`Transport error: ${err.message}`);
210
+ await transport.start();
211
+ log("Connected to remote server using StreamableHTTPClientTransport.");
212
+
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).
217
+ transport.onmessage = (message) => {
218
+ process.stdout.write(`${JSON.stringify(message)}\n`);
219
+ };
220
+
221
+ let buffer = "";
222
+ process.stdin.setEncoding("utf8");
223
+ process.stdin.on("data", (chunk) => {
224
+ buffer += chunk;
225
+ let newlineIndex;
226
+ while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
227
+ const line = buffer.slice(0, newlineIndex);
228
+ buffer = buffer.slice(newlineIndex + 1);
229
+ if (!line.trim()) continue;
230
+ (async () => {
231
+ try {
232
+ if (expiresSoon(tokens)) {
233
+ tokens = await getValidTokens();
234
+ }
235
+ await transport.send(JSON.parse(line));
236
+ } catch (err) {
237
+ log(`Send failed: ${err.message}`);
238
+ }
239
+ })();
240
+ }
241
+ });
242
+
243
+ process.stdin.on("end", async () => {
244
+ log("stdin closed, shutting down.");
245
+ await transport.close();
246
+ process.exit(0);
247
+ });
248
+
249
+ log("Local STDIO proxy running. Press Ctrl+C to exit.");
250
+ }
251
+
252
+ main().catch((err) => {
253
+ log(`Fatal error: ${err.stack ?? err.message}`);
254
+ process.exit(1);
255
+ });