@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 +21 -0
- package/roamer-device-auth.mjs +255 -0
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
|
+
});
|