@mathismeadows/roamer-device-auth 1.1.3 → 1.2.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 +2 -2
- package/roamer-device-auth.mjs +286 -20
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathismeadows/roamer-device-auth",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "AUTH-25:
|
|
6
|
+
"description": "AUTH-14/AUTH-25: stdio<->HTTP MCP proxy for Roamer MCP with two auth mechanisms, chosen per-machine by default-browser detection. Safari-default machines use a server-mediated device authorization flow (AUTH-25) since Safari's HTTPS-Only Mode blocks a loopback redirect; every other machine uses a standard direct loopback redirect against Cloudflare Access Managed OAuth instead.",
|
|
7
7
|
"bin": {
|
|
8
8
|
"roamer-device-auth": "./roamer-device-auth.mjs"
|
|
9
9
|
},
|
package/roamer-device-auth.mjs
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Roamer MCP — server-mediated device authorization flow (AUTH-25)
|
|
2
|
+
// Roamer MCP — server-mediated device authorization flow (AUTH-25), used only for
|
|
3
|
+
// Safari-default clients since AUTH-14's 2026-08-25 revival. Non-Safari clients (and any
|
|
4
|
+
// OS where Safari isn't an option, e.g. Windows/Linux) instead use a standard direct
|
|
5
|
+
// loopback-redirect flow against Cloudflare Access Managed OAuth — see
|
|
6
|
+
// getValidTokensLoopback() below — removing the device-code confirmation screen AUTH-25's
|
|
7
|
+
// prior unified design imposed on every client, Safari or not.
|
|
3
8
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
+
// Why Safari still needs device-code: Safari's HTTPS-Only Mode unconditionally blocks the
|
|
10
|
+
// loopback flow's plain http:// callback (WebKitErrorDomain:305, confirmed live
|
|
11
|
+
// 2026-08-20), and Cloudflare Access's Managed OAuth has no device-code grant of its own
|
|
12
|
+
// (confirmed live the same day — device authentication is not supported for MCP portals
|
|
13
|
+
// per Cloudflare's own docs), so RoamerMcp's server implements the RFC 8628 shape itself
|
|
14
|
+
// for that case, brokering a real Authorization Code + PKCE exchange with Cloudflare
|
|
15
|
+
// server-to-server. detectDefaultBrowser() below decides which path a given machine takes.
|
|
14
16
|
//
|
|
15
17
|
// AUTH-24: this is the publishable source for the @mathismeadows/roamer-device-auth npm
|
|
16
18
|
// package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
|
|
@@ -19,13 +21,23 @@
|
|
|
19
21
|
// stdout is reserved for the JSON-RPC protocol channel; all logging goes to stderr.
|
|
20
22
|
|
|
21
23
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
24
|
+
import {
|
|
25
|
+
discoverOAuthServerInfo,
|
|
26
|
+
extractWWWAuthenticateParams,
|
|
27
|
+
registerClient,
|
|
28
|
+
startAuthorization,
|
|
29
|
+
exchangeAuthorization,
|
|
30
|
+
refreshAuthorization,
|
|
31
|
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
22
32
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
33
|
+
import { realpathSync } from "node:fs";
|
|
23
34
|
import { homedir } from "node:os";
|
|
24
35
|
import { join } from "node:path";
|
|
25
36
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
26
37
|
import { execFile } from "node:child_process";
|
|
27
38
|
import { promisify } from "node:util";
|
|
28
39
|
import { pathToFileURL } from "node:url";
|
|
40
|
+
import { createServer } from "node:http";
|
|
29
41
|
import qrcode from "qrcode-terminal";
|
|
30
42
|
|
|
31
43
|
const execFileAsync = promisify(execFile);
|
|
@@ -37,6 +49,13 @@ const CACHE_DIR = join(homedir(), ".mcp-auth-device");
|
|
|
37
49
|
const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
|
|
38
50
|
const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
|
|
39
51
|
|
|
52
|
+
// AUTH-14: the loopback flow's own independent cache — never assumed interchangeable with
|
|
53
|
+
// the device-code cache above, matching this file's existing precedent of not trusting a
|
|
54
|
+
// differently-obtained cache just because it happens to share field names (see
|
|
55
|
+
// CACHE_VERSION's own comment on the mcp-remote-era predecessor of that exact mistake).
|
|
56
|
+
const LOOPBACK_TOKENS_FILE = join(CACHE_DIR, "roamer_loopback_tokens.json");
|
|
57
|
+
const LOOPBACK_CLIENT_FILE = join(CACHE_DIR, "roamer_loopback_client.json");
|
|
58
|
+
|
|
40
59
|
// Bumped whenever the cached shape changes meaningfully. A cache written by a prior
|
|
41
60
|
// mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
|
|
42
61
|
// (access_token, expires_in, obtained_at) with this format, so a plain presence check isn't
|
|
@@ -92,6 +111,69 @@ function clearCachedTokens() {
|
|
|
92
111
|
return writeFile(CACHE_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
93
112
|
}
|
|
94
113
|
|
|
114
|
+
// AUTH-14: loopback-flow cache — a brand-new file, never previously used by any other
|
|
115
|
+
// mechanism, so (unlike CACHE_FILE above) there's no prior-format collision risk to guard
|
|
116
|
+
// against with a version stamp.
|
|
117
|
+
async function readLoopbackTokens() {
|
|
118
|
+
try {
|
|
119
|
+
const tokens = JSON.parse(await readFile(LOOPBACK_TOKENS_FILE, "utf8"));
|
|
120
|
+
return tokens?.access_token ? tokens : null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function writeLoopbackTokens(tokens) {
|
|
127
|
+
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
128
|
+
await writeFile(LOOPBACK_TOKENS_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function clearLoopbackTokens() {
|
|
132
|
+
return writeFile(LOOPBACK_TOKENS_FILE, "{}", { mode: 0o600 }).catch(() => {});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// DCR'd client registration is reused across runs — Cloudflare's registration_endpoint has
|
|
136
|
+
// no reason to be hit on every single sign-in, only the first one (or after a reset).
|
|
137
|
+
async function readLoopbackClientInfo() {
|
|
138
|
+
try {
|
|
139
|
+
const info = JSON.parse(await readFile(LOOPBACK_CLIENT_FILE, "utf8"));
|
|
140
|
+
return info?.client_id ? info : null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function writeLoopbackClientInfo(info) {
|
|
147
|
+
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
148
|
+
await writeFile(LOOPBACK_CLIENT_FILE, JSON.stringify(info, null, 2), { mode: 0o600 });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
|
|
152
|
+
// Safari isn't an option elsewhere) take the device-code path above, since Safari's
|
|
153
|
+
// HTTPS-Only Mode blocks the loopback callback outright. Everything else, including every
|
|
154
|
+
// non-macOS platform (no Launch Services plist to query, so this always reports "unknown"
|
|
155
|
+
// there — correctly falling through to the loopback path), takes the lighter direct
|
|
156
|
+
// redirect. ROAMER_MCP_AUTH_FLOW overrides detection entirely — used by this file's own
|
|
157
|
+
// test suite to pin a deterministic path regardless of the CI machine's real OS/browser.
|
|
158
|
+
async function detectDefaultBrowser() {
|
|
159
|
+
const override = process.env.ROAMER_MCP_AUTH_FLOW;
|
|
160
|
+
if (override === "device-code") return "com.apple.safari";
|
|
161
|
+
if (override === "loopback") return "unknown";
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const plistPath = join(
|
|
165
|
+
homedir(),
|
|
166
|
+
"Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
|
|
167
|
+
);
|
|
168
|
+
const { stdout } = await execFileAsync("plutil", ["-convert", "json", "-o", "-", plistPath]);
|
|
169
|
+
const data = JSON.parse(stdout);
|
|
170
|
+
const handler = (data.LSHandlers ?? []).find((h) => h.LSHandlerURLScheme === "http");
|
|
171
|
+
return handler?.LSHandlerRoleAll ?? "unknown";
|
|
172
|
+
} catch {
|
|
173
|
+
return "unknown";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
95
177
|
// Claude Code (or any MCP host) may kill this process and respawn a fresh one if it doesn't
|
|
96
178
|
// see a stdio handshake within its own connect timeout — and the interactive device flow
|
|
97
179
|
// (dialog -> browser -> Cloudflare's consent screen -> poll) routinely takes longer than a
|
|
@@ -259,13 +341,179 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
259
341
|
}
|
|
260
342
|
}
|
|
261
343
|
|
|
344
|
+
// AUTH-14: fixed, well-known port for the loopback callback — Cloudflare's DCR'd client
|
|
345
|
+
// registration is persisted and reused across every future sign-in (see
|
|
346
|
+
// readLoopbackClientInfo), so the redirect_uri baked into that registration has to stay
|
|
347
|
+
// stable across runs. A fixed port sidesteps re-registering on every single interactive
|
|
348
|
+
// sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
|
|
349
|
+
// project's original AUTH-11/AUTH-14 mcp-remote-based flow already lived with for months.
|
|
350
|
+
const LOOPBACK_PORT = Number(process.env.ROAMER_MCP_OAUTH_PORT ?? 38271);
|
|
351
|
+
const LOOPBACK_REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
|
|
352
|
+
|
|
353
|
+
async function refreshLoopbackTokens(refreshToken, serverInfo, clientInformation) {
|
|
354
|
+
const fresh = await refreshAuthorization(serverInfo.authorizationServerUrl, {
|
|
355
|
+
metadata: serverInfo.authorizationServerMetadata,
|
|
356
|
+
clientInformation,
|
|
357
|
+
refreshToken,
|
|
358
|
+
});
|
|
359
|
+
return { ...fresh, refresh_token: fresh.refresh_token ?? refreshToken };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function waitForLoopbackCallback(server) {
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
server.on("request", (req, res) => {
|
|
365
|
+
const url = new URL(req.url, LOOPBACK_REDIRECT_URI);
|
|
366
|
+
if (url.pathname !== "/callback") {
|
|
367
|
+
res.writeHead(404);
|
|
368
|
+
res.end();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const code = url.searchParams.get("code");
|
|
372
|
+
const error = url.searchParams.get("error");
|
|
373
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
374
|
+
res.end(
|
|
375
|
+
error
|
|
376
|
+
? `<html><body>Sign-in failed: ${error}. You can close this window.</body></html>`
|
|
377
|
+
: `<html><body>Signed in to Roamer MCP. You can close this window.</body></html>`,
|
|
378
|
+
);
|
|
379
|
+
if (error) reject(new Error(`Loopback authorization failed: ${error}`));
|
|
380
|
+
else if (code) resolve(code);
|
|
381
|
+
else reject(new Error("Loopback callback had neither code nor error"));
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// AUTH-14: must follow the real WWW-Authenticate resource_metadata hint from a live 401,
|
|
387
|
+
// not RoamerMcp's own default .well-known/oauth-protected-resource document, which still
|
|
388
|
+
// mirrors Entra directly and would send a standards-compliant client to the wrong
|
|
389
|
+
// authorization server entirely — confirmed live 2026-08-26 (see thread
|
|
390
|
+
// SAFARI-DEVICE-CODE-UX; the stale default document itself is a separate, lower-priority
|
|
391
|
+
// gap tracked as AUTH-27, not fixed by this flow following the correct header instead).
|
|
392
|
+
async function discoverLoopbackServerInfo() {
|
|
393
|
+
const probe = await fetch(ROAMER_MCP_URL, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: { "Content-Type": "application/json" },
|
|
396
|
+
body: "{}",
|
|
397
|
+
});
|
|
398
|
+
const { resourceMetadataUrl } = extractWWWAuthenticateParams(probe);
|
|
399
|
+
await probe.body?.cancel?.();
|
|
400
|
+
return discoverOAuthServerInfo(ROAMER_MCP_URL, { resourceMetadataUrl });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Same single-flight guard shape as getValidTokens above, kept independent since only one
|
|
404
|
+
// of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
|
|
405
|
+
let inFlightLoopbackTokens = null;
|
|
406
|
+
|
|
407
|
+
function getValidTokensLoopback(forceRefresh = false) {
|
|
408
|
+
if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
|
|
409
|
+
inFlightLoopbackTokens = doGetValidTokensLoopback(forceRefresh).finally(() => {
|
|
410
|
+
inFlightLoopbackTokens = null;
|
|
411
|
+
});
|
|
412
|
+
return inFlightLoopbackTokens;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function doGetValidTokensLoopback(forceRefresh) {
|
|
416
|
+
let tokens = forceRefresh ? null : await readLoopbackTokens();
|
|
417
|
+
|
|
418
|
+
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
419
|
+
return tokens;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const serverInfo = await discoverLoopbackServerInfo();
|
|
423
|
+
let clientInformation = await readLoopbackClientInfo();
|
|
424
|
+
|
|
425
|
+
if (!forceRefresh && tokens?.refresh_token && clientInformation) {
|
|
426
|
+
try {
|
|
427
|
+
log("Refreshing cached token...");
|
|
428
|
+
const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
|
|
429
|
+
tokens = { ...fresh, obtained_at: Date.now() };
|
|
430
|
+
await writeLoopbackTokens(tokens);
|
|
431
|
+
return tokens;
|
|
432
|
+
} catch (err) {
|
|
433
|
+
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (forceRefresh) await clearLoopbackTokens();
|
|
438
|
+
|
|
439
|
+
if (!clientInformation) {
|
|
440
|
+
log("Registering as a new OAuth client with Cloudflare...");
|
|
441
|
+
clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
|
|
442
|
+
metadata: serverInfo.authorizationServerMetadata,
|
|
443
|
+
clientMetadata: {
|
|
444
|
+
client_name: "Roamer MCP (stdio bridge)",
|
|
445
|
+
redirect_uris: [LOOPBACK_REDIRECT_URI],
|
|
446
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
447
|
+
response_types: ["code"],
|
|
448
|
+
token_endpoint_auth_method: "none",
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
await writeLoopbackClientInfo(clientInformation);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
log("Starting sign-in...");
|
|
455
|
+
const { authorizationUrl, codeVerifier } = await startAuthorization(serverInfo.authorizationServerUrl, {
|
|
456
|
+
metadata: serverInfo.authorizationServerMetadata,
|
|
457
|
+
clientInformation,
|
|
458
|
+
redirectUrl: LOOPBACK_REDIRECT_URI,
|
|
459
|
+
// RFC 8707: Cloudflare's authorization endpoint rejects the request without this
|
|
460
|
+
// (invalid_target / "No resource parameter found") — the exact same requirement
|
|
461
|
+
// DeviceFlowService.cs already hit and fixed server-side for the device-code path
|
|
462
|
+
// (confirmed live 2026-08-21) and just reconfirmed live here 2026-08-26. Only the
|
|
463
|
+
// authorization step needs it; DeviceFlowService.cs's token/refresh calls don't
|
|
464
|
+
// reference it at all, so exchangeAuthorization/refreshAuthorization below don't either.
|
|
465
|
+
resource: new URL(ROAMER_MCP_URL),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const server = createServer();
|
|
469
|
+
const callbackPromise = waitForLoopbackCallback(server);
|
|
470
|
+
await new Promise((resolve, reject) => {
|
|
471
|
+
server.once("error", reject);
|
|
472
|
+
server.listen(LOOPBACK_PORT, "127.0.0.1", resolve);
|
|
473
|
+
}).catch((err) => {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`Could not start the local sign-in listener on port ${LOOPBACK_PORT} (${err.message}). ` +
|
|
476
|
+
"Another roamer-device-auth process may already be signing in — wait for it to finish, " +
|
|
477
|
+
"or set ROAMER_MCP_OAUTH_PORT to a free port.",
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
log(`Go to ${authorizationUrl} to sign in.`);
|
|
482
|
+
try {
|
|
483
|
+
const { default: open } = await import("open");
|
|
484
|
+
await open(authorizationUrl.toString());
|
|
485
|
+
} catch {
|
|
486
|
+
// Best-effort only — the logged URL above is the fallback for any environment without a
|
|
487
|
+
// way to auto-launch a browser.
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let authorizationCode;
|
|
491
|
+
try {
|
|
492
|
+
authorizationCode = await callbackPromise;
|
|
493
|
+
} finally {
|
|
494
|
+
server.close();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const fresh = await exchangeAuthorization(serverInfo.authorizationServerUrl, {
|
|
498
|
+
metadata: serverInfo.authorizationServerMetadata,
|
|
499
|
+
clientInformation,
|
|
500
|
+
authorizationCode,
|
|
501
|
+
codeVerifier,
|
|
502
|
+
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
503
|
+
});
|
|
504
|
+
tokens = { ...fresh, obtained_at: Date.now() };
|
|
505
|
+
await writeLoopbackTokens(tokens);
|
|
506
|
+
log("Sign-in complete.");
|
|
507
|
+
return tokens;
|
|
508
|
+
}
|
|
509
|
+
|
|
262
510
|
// AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
|
|
263
511
|
// queued while sign-in was still in progress, with identical forwarding/retry behavior.
|
|
264
|
-
async function forwardLine(transport, line, getTokens, setTokens) {
|
|
512
|
+
async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens) {
|
|
265
513
|
try {
|
|
266
514
|
let tokens = getTokens();
|
|
267
515
|
if (expiresSoon(tokens)) {
|
|
268
|
-
tokens = await
|
|
516
|
+
tokens = await getFreshTokens();
|
|
269
517
|
setTokens(tokens);
|
|
270
518
|
}
|
|
271
519
|
try {
|
|
@@ -277,7 +525,7 @@ async function forwardLine(transport, line, getTokens, setTokens) {
|
|
|
277
525
|
// whatever we're holding, force a genuinely fresh token, and retry once.
|
|
278
526
|
if (!isAuthError(err)) throw err;
|
|
279
527
|
log(`Send failed with an auth error (${err.message}) — invalidating cached token and retrying once.`);
|
|
280
|
-
tokens = await
|
|
528
|
+
tokens = await getFreshTokens(true);
|
|
281
529
|
setTokens(tokens);
|
|
282
530
|
await transport.send(JSON.parse(line));
|
|
283
531
|
}
|
|
@@ -319,6 +567,13 @@ async function main() {
|
|
|
319
567
|
tokens = fresh;
|
|
320
568
|
};
|
|
321
569
|
|
|
570
|
+
// AUTH-14: assigned below, only after the stdin listener is attached — forwardLine (the
|
|
571
|
+
// only consumer) never runs before `ready` flips true, which itself never happens before
|
|
572
|
+
// these are set, so deferring the assignment past the listener attach is safe and keeps
|
|
573
|
+
// AUTH-28's guarantee (listener attaches before any await) intact.
|
|
574
|
+
let getFreshTokens;
|
|
575
|
+
let clearFreshTokens;
|
|
576
|
+
|
|
322
577
|
let buffer = "";
|
|
323
578
|
process.stdin.setEncoding("utf8");
|
|
324
579
|
process.stdin.on("data", (chunk) => {
|
|
@@ -329,7 +584,7 @@ async function main() {
|
|
|
329
584
|
buffer = buffer.slice(newlineIndex + 1);
|
|
330
585
|
if (!line.trim()) continue;
|
|
331
586
|
if (ready) {
|
|
332
|
-
forwardLine(transport, line, getTokens, setTokens);
|
|
587
|
+
forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
|
|
333
588
|
} else {
|
|
334
589
|
pendingLines.push(line);
|
|
335
590
|
}
|
|
@@ -344,8 +599,16 @@ async function main() {
|
|
|
344
599
|
|
|
345
600
|
log("Local STDIO proxy running. Press Ctrl+C to exit.");
|
|
346
601
|
|
|
602
|
+
// AUTH-14: decided once per process — detectDefaultBrowser() shells out to Launch
|
|
603
|
+
// Services, no need to re-check mid-session. Safari-default machines keep using the
|
|
604
|
+
// existing device-code mechanism (AUTH-25); everything else uses the lighter loopback
|
|
605
|
+
// redirect, including refreshes and reactive re-auth below.
|
|
606
|
+
const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
|
|
607
|
+
getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
608
|
+
clearFreshTokens = isSafari ? clearCachedTokens : clearLoopbackTokens;
|
|
609
|
+
|
|
347
610
|
try {
|
|
348
|
-
tokens = await
|
|
611
|
+
tokens = await getFreshTokens();
|
|
349
612
|
} catch (err) {
|
|
350
613
|
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
351
614
|
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
@@ -368,7 +631,7 @@ async function main() {
|
|
|
368
631
|
// Same reasoning as the send-path retry above: an auth error means whatever's cached is
|
|
369
632
|
// known-bad, so drop it now rather than let the next proactive expiresSoon() check
|
|
370
633
|
// (which only reasons about calendar time) keep handing it out.
|
|
371
|
-
if (isAuthError(err))
|
|
634
|
+
if (isAuthError(err)) clearFreshTokens();
|
|
372
635
|
};
|
|
373
636
|
// stdin/stdout <-> transport pass-through. Each stdin line is one JSON-RPC message;
|
|
374
637
|
// transport.send() delivers it, and transport.onmessage delivers whatever comes back
|
|
@@ -382,13 +645,16 @@ async function main() {
|
|
|
382
645
|
|
|
383
646
|
ready = true;
|
|
384
647
|
for (const line of pendingLines) {
|
|
385
|
-
forwardLine(transport, line, getTokens, setTokens);
|
|
648
|
+
forwardLine(transport, line, getTokens, setTokens, getFreshTokens);
|
|
386
649
|
}
|
|
387
650
|
}
|
|
388
651
|
|
|
389
652
|
// Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
|
|
390
653
|
// must not trigger a live device-auth flow and stdio takeover as a side effect.
|
|
391
|
-
|
|
654
|
+
// AUTH-29: npx always launches this via the node_modules/.bin symlink, so argv[1] must be
|
|
655
|
+
// realpath-resolved before comparing — import.meta.url is already realpath-resolved by Node.
|
|
656
|
+
const isMainModule =
|
|
657
|
+
process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
392
658
|
if (isMainModule) {
|
|
393
659
|
main().catch((err) => {
|
|
394
660
|
log(`Fatal error: ${err.stack ?? err.message}`);
|
|
@@ -396,4 +662,4 @@ if (isMainModule) {
|
|
|
396
662
|
});
|
|
397
663
|
}
|
|
398
664
|
|
|
399
|
-
export { respondWithSignInError, forwardLine };
|
|
665
|
+
export { respondWithSignInError, forwardLine, detectDefaultBrowser };
|