@mathismeadows/roamer-device-auth 1.1.1 → 1.1.3
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 +1 -1
- package/roamer-device-auth.mjs +184 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathismeadows/roamer-device-auth",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
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.",
|
package/roamer-device-auth.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { join } from "node:path";
|
|
|
25
25
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
26
26
|
import { execFile } from "node:child_process";
|
|
27
27
|
import { promisify } from "node:util";
|
|
28
|
+
import { pathToFileURL } from "node:url";
|
|
28
29
|
import qrcode from "qrcode-terminal";
|
|
29
30
|
|
|
30
31
|
const execFileAsync = promisify(execFile);
|
|
@@ -34,6 +35,7 @@ const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
|
|
|
34
35
|
|
|
35
36
|
const CACHE_DIR = join(homedir(), ".mcp-auth-device");
|
|
36
37
|
const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
|
|
38
|
+
const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
|
|
37
39
|
|
|
38
40
|
// Bumped whenever the cached shape changes meaningfully. A cache written by a prior
|
|
39
41
|
// mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
|
|
@@ -90,6 +92,35 @@ function clearCachedTokens() {
|
|
|
90
92
|
return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
// Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
|
|
96
|
+
// see a stdio handshake within its own connect timeout — and the interactive device flow
|
|
97
|
+
// (dialog -> browser -> Cloudflare's consent screen -> poll) routinely takes longer than a
|
|
98
|
+
// human can click through inside a short timeout. Without persisting the in-progress flow,
|
|
99
|
+
// every respawn called /oauth/device/start again, which mints a BRAND NEW device_code/
|
|
100
|
+
// user_code — the user would see a different code every single retry, forever, with no
|
|
101
|
+
// path to ever actually finish signing in. Confirmed live 2026-08-21. Persisting the
|
|
102
|
+
// pending flow means a respawned process resumes polling the SAME still-valid code instead.
|
|
103
|
+
async function readPendingFlow() {
|
|
104
|
+
try {
|
|
105
|
+
const pending = JSON.parse(await readFile(PENDING_FILE, "utf8"));
|
|
106
|
+
if (!pending?.device_code || Date.now() > pending.expiresAt) return null;
|
|
107
|
+
return pending;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function writePendingFlow(device) {
|
|
114
|
+
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
115
|
+
const pending = { ...device, expiresAt: Date.now() + device.expires_in * 1000 };
|
|
116
|
+
await writeFile(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
|
|
117
|
+
return pending;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clearPendingFlow() {
|
|
121
|
+
return writeFile(PENDING_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
122
|
+
}
|
|
123
|
+
|
|
93
124
|
function expiresSoon(tokens) {
|
|
94
125
|
// expires_in is optional in the response shape — an absent value means treat the token as
|
|
95
126
|
// long-lived and rely on a real 401 to trigger re-auth rather than guessing a lifetime.
|
|
@@ -178,36 +209,153 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
178
209
|
|
|
179
210
|
if (forceRefresh) await clearCachedTokens();
|
|
180
211
|
|
|
181
|
-
|
|
182
|
-
|
|
212
|
+
// Resume an already-in-progress flow (from a process this host killed and respawned)
|
|
213
|
+
// instead of minting a new device_code the user would have to start over for.
|
|
214
|
+
let device = await readPendingFlow();
|
|
215
|
+
let resuming = Boolean(device);
|
|
216
|
+
if (!resuming) {
|
|
217
|
+
log("Starting sign-in...");
|
|
218
|
+
device = await startDeviceFlow();
|
|
219
|
+
await writePendingFlow(device);
|
|
220
|
+
} else {
|
|
221
|
+
log("Resuming an already-in-progress sign-in (a prior process was restarted before it finished)...");
|
|
222
|
+
}
|
|
223
|
+
|
|
183
224
|
log(`Go to ${device.verification_uri} and enter code: ${device.user_code}`);
|
|
184
225
|
// Fire-and-forget: the dialog is how the human actually sees this on macOS; polling below
|
|
185
|
-
// must not wait on it being dismissed.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
226
|
+
// must not wait on it being dismissed. Skip re-showing it on a resume — the human already
|
|
227
|
+
// saw it (or is mid-flow in the browser) from the process that started this same code.
|
|
228
|
+
if (!resuming) showDeviceCodeDialog(device.verification_uri, device.user_code);
|
|
229
|
+
if (!resuming) {
|
|
230
|
+
try {
|
|
231
|
+
const { default: open } = await import("open");
|
|
232
|
+
await open(device.verification_uri_complete);
|
|
233
|
+
} catch {
|
|
234
|
+
// Best-effort only — the dialog and QR code below already carry the URL and code.
|
|
235
|
+
}
|
|
192
236
|
}
|
|
193
237
|
// Terminal-visible for any client where a human sees this process's stderr/log output
|
|
194
238
|
// (Claude Code CLI, VS Code's integrated terminal) — QR codes are a real usability win
|
|
195
239
|
// for CLI auth over typing an 8-character code by hand.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
240
|
+
if (!resuming) {
|
|
241
|
+
qrcode.generate(device.verification_uri_complete, { small: true }, (qr) => {
|
|
242
|
+
process.stderr.write(`${qr}\n`);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
199
245
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
246
|
+
try {
|
|
247
|
+
const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
|
|
248
|
+
tokens = { ...fresh, obtained_at: Date.now() };
|
|
249
|
+
await writeCachedTokens(tokens);
|
|
250
|
+
await clearPendingFlow();
|
|
251
|
+
log("Sign-in complete.");
|
|
252
|
+
return tokens;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
// A hard failure (expired/denied, not just this process being killed) means the pending
|
|
255
|
+
// code is genuinely dead — clear it so the next attempt starts a real fresh one instead
|
|
256
|
+
// of retrying a code that will never succeed.
|
|
257
|
+
await clearPendingFlow();
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
|
|
263
|
+
// queued while sign-in was still in progress, with identical forwarding/retry behavior.
|
|
264
|
+
async function forwardLine(transport, line, getTokens, setTokens) {
|
|
265
|
+
try {
|
|
266
|
+
let tokens = getTokens();
|
|
267
|
+
if (expiresSoon(tokens)) {
|
|
268
|
+
tokens = await getValidTokens();
|
|
269
|
+
setTokens(tokens);
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await transport.send(JSON.parse(line));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
// Reactive invalidation: proactive expiry math can't catch everything (server-side
|
|
275
|
+
// revocation, clock skew, or an incompatible cache — see CACHE_VERSION). A real
|
|
276
|
+
// auth failure from the server is the ground truth; when we see one, invalidate
|
|
277
|
+
// whatever we're holding, force a genuinely fresh token, and retry once.
|
|
278
|
+
if (!isAuthError(err)) throw err;
|
|
279
|
+
log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
|
|
280
|
+
tokens = await getValidTokens(true);
|
|
281
|
+
setTokens(tokens);
|
|
282
|
+
await transport.send(JSON.parse(line));
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
log(`Send failed: ${err.message}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// AUTH-28: a hard sign-in failure (denied/expired device code) must tell the host exactly
|
|
290
|
+
// that, per request, rather than leaving requests unanswered for the host to time out on.
|
|
291
|
+
function respondWithSignInError(line, err) {
|
|
292
|
+
let id = null;
|
|
293
|
+
try {
|
|
294
|
+
id = JSON.parse(line)?.id ?? null;
|
|
295
|
+
} catch {
|
|
296
|
+
// Malformed input never had a usable id anyway — respond with null per JSON-RPC convention.
|
|
297
|
+
}
|
|
298
|
+
const response = {
|
|
299
|
+
jsonrpc: "2.0",
|
|
300
|
+
id,
|
|
301
|
+
error: { code: -32001, message: `Roamer MCP sign-in failed: ${err.message}` },
|
|
302
|
+
};
|
|
303
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
205
304
|
}
|
|
206
305
|
|
|
207
306
|
async function main() {
|
|
208
|
-
|
|
307
|
+
// AUTH-28: stdin is read (and, until sign-in completes, queued) from the very first tick —
|
|
308
|
+
// a cold-cache device-code flow can take minutes, and the MCP host must never see this
|
|
309
|
+
// process as unresponsive/silent during that window. That silence, not the auth flow
|
|
310
|
+
// itself, is what triggers the host's connect-timeout kill/respawn (see readPendingFlow's
|
|
311
|
+
// comment above) — this fix targets the silence, not the flow's real, unavoidable duration.
|
|
312
|
+
let tokens = null;
|
|
313
|
+
let transport = null;
|
|
314
|
+
let ready = false;
|
|
315
|
+
const pendingLines = [];
|
|
316
|
+
|
|
317
|
+
const getTokens = () => tokens;
|
|
318
|
+
const setTokens = (fresh) => {
|
|
319
|
+
tokens = fresh;
|
|
320
|
+
};
|
|
209
321
|
|
|
210
|
-
|
|
322
|
+
let buffer = "";
|
|
323
|
+
process.stdin.setEncoding("utf8");
|
|
324
|
+
process.stdin.on("data", (chunk) => {
|
|
325
|
+
buffer += chunk;
|
|
326
|
+
let newlineIndex;
|
|
327
|
+
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
|
|
328
|
+
const line = buffer.slice(0, newlineIndex);
|
|
329
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
330
|
+
if (!line.trim()) continue;
|
|
331
|
+
if (ready) {
|
|
332
|
+
forwardLine(transport, line, getTokens, setTokens);
|
|
333
|
+
} else {
|
|
334
|
+
pendingLines.push(line);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
process.stdin.on("end", async () => {
|
|
340
|
+
log("stdin closed, shutting down.");
|
|
341
|
+
if (transport) await transport.close();
|
|
342
|
+
process.exit(0);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
log("Local STDIO proxy running. Press Ctrl+C to exit.");
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
tokens = await getValidTokens();
|
|
349
|
+
} catch (err) {
|
|
350
|
+
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
351
|
+
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
352
|
+
// explicit failure instead of a process it has to silently time out on.
|
|
353
|
+
log(`Sign-in failed: ${err.message}`);
|
|
354
|
+
for (const line of pendingLines) respondWithSignInError(line, err);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
|
|
211
359
|
requestInit: {
|
|
212
360
|
get headers() {
|
|
213
361
|
return { Authorization: `Bearer ${tokens.access_token}` };
|
|
@@ -217,14 +365,11 @@ async function main() {
|
|
|
217
365
|
|
|
218
366
|
transport.onerror = (err) => {
|
|
219
367
|
log(`Transport error: ${err.message}`);
|
|
220
|
-
// Same reasoning as the send-path retry
|
|
368
|
+
// Same reasoning as the send-path retry above: an auth error means whatever's cached is
|
|
221
369
|
// known-bad, so drop it now rather than let the next proactive expiresSoon() check
|
|
222
370
|
// (which only reasons about calendar time) keep handing it out.
|
|
223
371
|
if (isAuthError(err)) clearCachedTokens();
|
|
224
372
|
};
|
|
225
|
-
await transport.start();
|
|
226
|
-
log("Connected to remote server using StreamableHTTPClientTransport.");
|
|
227
|
-
|
|
228
373
|
// stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
|
|
229
374
|
// transport.send() delivers it, and transport.onmessage delivers whatever comes back
|
|
230
375
|
// (including server-initiated messages over the SSE half of the streamable-HTTP transport).
|
|
@@ -232,49 +377,23 @@ async function main() {
|
|
|
232
377
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
233
378
|
};
|
|
234
379
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
process.stdin.on("data", (chunk) => {
|
|
238
|
-
buffer += chunk;
|
|
239
|
-
let newlineIndex;
|
|
240
|
-
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
|
|
241
|
-
const line = buffer.slice(0, newlineIndex);
|
|
242
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
243
|
-
if (!line.trim()) continue;
|
|
244
|
-
(async () => {
|
|
245
|
-
try {
|
|
246
|
-
if (expiresSoon(tokens)) {
|
|
247
|
-
tokens = await getValidTokens();
|
|
248
|
-
}
|
|
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
|
-
}
|
|
261
|
-
} catch (err) {
|
|
262
|
-
log(`Send failed: ${err.message}`);
|
|
263
|
-
}
|
|
264
|
-
})();
|
|
265
|
-
}
|
|
266
|
-
});
|
|
380
|
+
await transport.start();
|
|
381
|
+
log("Connected to remote server using StreamableHTTPClientTransport.");
|
|
267
382
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
383
|
+
ready = true;
|
|
384
|
+
for (const line of pendingLines) {
|
|
385
|
+
forwardLine(transport, line, getTokens, setTokens);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
273
388
|
|
|
274
|
-
|
|
389
|
+
// Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
|
|
390
|
+
// must not trigger a live device-auth flow and stdio takeover as a side effect.
|
|
391
|
+
const isMainModule = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
392
|
+
if (isMainModule) {
|
|
393
|
+
main().catch((err) => {
|
|
394
|
+
log(`Fatal error: ${err.stack ?? err.message}`);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
});
|
|
275
397
|
}
|
|
276
398
|
|
|
277
|
-
|
|
278
|
-
log(`Fatal error: ${err.stack ?? err.message}`);
|
|
279
|
-
process.exit(1);
|
|
280
|
-
});
|
|
399
|
+
export { respondWithSignInError, forwardLine };
|