@mathismeadows/roamer-device-auth 1.3.0 → 1.4.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 +2 -1
- package/roamer-device-auth.mjs +197 -64
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathismeadows/roamer-device-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"private": false,
|
|
5
|
+
"mcpName": "com.mathismeadows/roamer-mcp",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"description": "AUTH-14/AUTH-25/AUTH-38: 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 instead. Both authenticate against RoamerMcp's own OAuth authorization server (AUTH-38).",
|
|
7
8
|
"bin": {
|
package/roamer-device-auth.mjs
CHANGED
|
@@ -20,6 +20,18 @@
|
|
|
20
20
|
// package — roamer-bridge.sh (here and in roamer-mcp-plugin) invokes the published,
|
|
21
21
|
// version-pinned package via npx rather than running this file in place.
|
|
22
22
|
//
|
|
23
|
+
// AUTH-51: every cached credential is namespaced per connecting MCP client, not shared
|
|
24
|
+
// machine-wide. Before this, one interactive sign-in (say, from Claude Code) silently
|
|
25
|
+
// authenticated every other local MCP host that later spawned this same bridge (e.g.
|
|
26
|
+
// Cursor) — no re-consent, no visibility, no way to revoke one without the other. Root
|
|
27
|
+
// cause and full context: spec item AUTH-51. Client identity comes from the `clientInfo.name`
|
|
28
|
+
// an MCP host sends in its own `initialize` request — the only client-identifying signal
|
|
29
|
+
// the stdio transport offers. This is NOT a security boundary (any local process can
|
|
30
|
+
// fabricate that name, or read these cache files directly — same-account processes always
|
|
31
|
+
// can) — see AUTH-51's explicit non-goal. Its job is stopping *accidental* sharing between
|
|
32
|
+
// distinct, legitimate MCP hosts, and making any reuse of a cached session visible via an OS
|
|
33
|
+
// notification rather than silent.
|
|
34
|
+
//
|
|
23
35
|
// stdout is reserved for the JSON-RPC protocol channel; all logging goes to stderr.
|
|
24
36
|
|
|
25
37
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
@@ -48,15 +60,16 @@ const ROAMER_MCP_URL = process.env.ROAMER_MCP_URL ?? "https://roamer-mcp.mathism
|
|
|
48
60
|
const ROAMER_MCP_ORIGIN = new URL(ROAMER_MCP_URL).origin;
|
|
49
61
|
|
|
50
62
|
const CACHE_DIR = join(homedir(), ".mcp-auth-device");
|
|
51
|
-
const CACHE_FILE = join(CACHE_DIR, "roamer_tokens.json");
|
|
52
|
-
const PENDING_FILE = join(CACHE_DIR, "roamer_pending.json");
|
|
53
63
|
|
|
54
|
-
// AUTH-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
// AUTH-51: every on-disk cache is namespaced per client slug — a null slug (the connecting
|
|
65
|
+
// host sent no usable clientInfo.name) means "never persist": the caller gets a real
|
|
66
|
+
// interactive sign-in on every single invocation instead of falling into any shared
|
|
67
|
+
// catch-all bucket, which would just recreate the bug this item fixes for a smaller
|
|
68
|
+
// population of clients.
|
|
69
|
+
function cacheFilePath(baseName, clientSlug) {
|
|
70
|
+
if (!clientSlug) return null;
|
|
71
|
+
return join(CACHE_DIR, `${baseName}__${clientSlug}.json`);
|
|
72
|
+
}
|
|
60
73
|
|
|
61
74
|
// Bumped whenever the cached shape changes meaningfully. A cache written by a prior
|
|
62
75
|
// mechanism (e.g. the retired Entra-direct flow, AUTH-11/14) happens to share field names
|
|
@@ -77,6 +90,28 @@ function isAuthError(err) {
|
|
|
77
90
|
return /invalid_token|unauthorized|\b401\b/i.test(err?.message ?? "");
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
// AUTH-51: the only client-identifying signal MCP's stdio transport offers — the
|
|
94
|
+
// `clientInfo.name` an MCP host sends in its own `initialize` request, which by protocol is
|
|
95
|
+
// always the very first message a host sends. Deliberately tolerant: any parse failure or
|
|
96
|
+
// missing/blank name just means "no usable identity", never a thrown error — a
|
|
97
|
+
// non-compliant host must degrade gracefully (an uncached, always-fresh sign-in), not crash
|
|
98
|
+
// the bridge.
|
|
99
|
+
function clientSlugFromInitializeLine(line) {
|
|
100
|
+
try {
|
|
101
|
+
const message = JSON.parse(line);
|
|
102
|
+
const name = message?.params?.clientInfo?.name;
|
|
103
|
+
if (typeof name !== "string" || !name.trim()) return null;
|
|
104
|
+
const slug = name
|
|
105
|
+
.trim()
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
108
|
+
.replace(/^-+|-+$/g, "");
|
|
109
|
+
return slug || null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
// A native dialog is an OS-level surface, guaranteed visible independent of whether the MCP
|
|
81
116
|
// host surfaces this process's stderr anywhere a human will see it (confirmed on the old
|
|
82
117
|
// Entra-direct flow: a stderr-only first version left the user with no way to see the code).
|
|
@@ -91,9 +126,31 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
|
|
|
91
126
|
}
|
|
92
127
|
}
|
|
93
128
|
|
|
94
|
-
|
|
129
|
+
// AUTH-51: fires once per process, only when the token this run ends up using was obtained
|
|
130
|
+
// without any interactive step this time (a cache hit or a silent background refresh) — the
|
|
131
|
+
// exact moment that used to be completely invisible. Deliberately NOT fired for routine
|
|
132
|
+
// mid-session refreshes of an already-visible, already-established session (see forwardLine
|
|
133
|
+
// below) — only for the initial per-process acquisition, so a long-lived connection doesn't
|
|
134
|
+
// spam a notification every time its token happens to roll over.
|
|
135
|
+
async function notifySessionReused(clientSlug) {
|
|
136
|
+
const label = clientSlug ?? "an unidentified client";
|
|
137
|
+
const message = `Reused an existing Roamer MCP session for ${label}.`;
|
|
95
138
|
try {
|
|
96
|
-
|
|
139
|
+
await execFileAsync("osascript", [
|
|
140
|
+
"-e",
|
|
141
|
+
`display notification "${message.replace(/"/g, '\\"')}" with title "Roamer MCP"`,
|
|
142
|
+
]);
|
|
143
|
+
} catch {
|
|
144
|
+
// Best-effort, macOS-only — the stderr log line below is the fallback for every other OS.
|
|
145
|
+
}
|
|
146
|
+
log(message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readCachedTokens(clientSlug) {
|
|
150
|
+
const path = cacheFilePath("roamer_tokens", clientSlug);
|
|
151
|
+
if (!path) return null;
|
|
152
|
+
try {
|
|
153
|
+
const tokens = JSON.parse(await readFile(path, "utf8"));
|
|
97
154
|
// A cache from an incompatible prior format must never be trusted just because it
|
|
98
155
|
// happens to have the right field names — see CACHE_VERSION's comment.
|
|
99
156
|
if (tokens?.cacheVersion !== CACHE_VERSION) return null;
|
|
@@ -103,14 +160,18 @@ async function readCachedTokens() {
|
|
|
103
160
|
}
|
|
104
161
|
}
|
|
105
162
|
|
|
106
|
-
async function writeCachedTokens(tokens) {
|
|
163
|
+
async function writeCachedTokens(clientSlug, tokens) {
|
|
164
|
+
const path = cacheFilePath("roamer_tokens", clientSlug);
|
|
165
|
+
if (!path) return; // AUTH-51: no usable client identity — never persisted.
|
|
107
166
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
108
167
|
const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
|
|
109
|
-
await writeFile(
|
|
168
|
+
await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
110
169
|
}
|
|
111
170
|
|
|
112
|
-
function clearCachedTokens() {
|
|
113
|
-
|
|
171
|
+
function clearCachedTokens(clientSlug) {
|
|
172
|
+
const path = cacheFilePath("roamer_tokens", clientSlug);
|
|
173
|
+
if (!path) return Promise.resolve();
|
|
174
|
+
return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
|
|
114
175
|
}
|
|
115
176
|
|
|
116
177
|
// AUTH-14/AUTH-38: loopback-flow caches, stamped with the authorization server URL they were
|
|
@@ -120,9 +181,11 @@ function clearCachedTokens() {
|
|
|
120
181
|
// discovery documents from Cloudflare Access Managed OAuth to RoamerMcp's own AS, and a client_id
|
|
121
182
|
// or token cached from before that change would otherwise be silently carried forward and
|
|
122
183
|
// presented to a completely different issuer with no invalidation at all.
|
|
123
|
-
async function readLoopbackTokens(issuerUrl) {
|
|
184
|
+
async function readLoopbackTokens(clientSlug, issuerUrl) {
|
|
185
|
+
const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
|
|
186
|
+
if (!path) return null;
|
|
124
187
|
try {
|
|
125
|
-
const tokens = JSON.parse(await readFile(
|
|
188
|
+
const tokens = JSON.parse(await readFile(path, "utf8"));
|
|
126
189
|
if (tokens?.issuerUrl !== issuerUrl) return null;
|
|
127
190
|
return tokens?.access_token ? tokens : null;
|
|
128
191
|
} catch {
|
|
@@ -130,22 +193,28 @@ async function readLoopbackTokens(issuerUrl) {
|
|
|
130
193
|
}
|
|
131
194
|
}
|
|
132
195
|
|
|
133
|
-
async function writeLoopbackTokens(tokens, issuerUrl) {
|
|
196
|
+
async function writeLoopbackTokens(clientSlug, tokens, issuerUrl) {
|
|
197
|
+
const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
|
|
198
|
+
if (!path) return; // AUTH-51: no usable client identity — never persisted.
|
|
134
199
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
135
200
|
const stamped = { ...tokens, issuerUrl };
|
|
136
|
-
await writeFile(
|
|
201
|
+
await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
137
202
|
}
|
|
138
203
|
|
|
139
|
-
function clearLoopbackTokens() {
|
|
140
|
-
|
|
204
|
+
function clearLoopbackTokens(clientSlug) {
|
|
205
|
+
const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
|
|
206
|
+
if (!path) return Promise.resolve();
|
|
207
|
+
return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
|
|
141
208
|
}
|
|
142
209
|
|
|
143
210
|
// DCR'd client registration is reused across runs — the authorization server's
|
|
144
211
|
// registration_endpoint has no reason to be hit on every single sign-in, only the first one (or
|
|
145
212
|
// after a reset, or after an issuer change per this function's own issuerUrl check above).
|
|
146
|
-
async function readLoopbackClientInfo(issuerUrl) {
|
|
213
|
+
async function readLoopbackClientInfo(clientSlug, issuerUrl) {
|
|
214
|
+
const path = cacheFilePath("roamer_loopback_client", clientSlug);
|
|
215
|
+
if (!path) return null;
|
|
147
216
|
try {
|
|
148
|
-
const info = JSON.parse(await readFile(
|
|
217
|
+
const info = JSON.parse(await readFile(path, "utf8"));
|
|
149
218
|
if (info?.issuerUrl !== issuerUrl) return null;
|
|
150
219
|
return info?.client_id ? info : null;
|
|
151
220
|
} catch {
|
|
@@ -153,10 +222,12 @@ async function readLoopbackClientInfo(issuerUrl) {
|
|
|
153
222
|
}
|
|
154
223
|
}
|
|
155
224
|
|
|
156
|
-
async function writeLoopbackClientInfo(info, issuerUrl) {
|
|
225
|
+
async function writeLoopbackClientInfo(clientSlug, info, issuerUrl) {
|
|
226
|
+
const path = cacheFilePath("roamer_loopback_client", clientSlug);
|
|
227
|
+
if (!path) return; // AUTH-51: no usable client identity — never persisted.
|
|
157
228
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
158
229
|
const stamped = { ...info, issuerUrl };
|
|
159
|
-
await writeFile(
|
|
230
|
+
await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
160
231
|
}
|
|
161
232
|
|
|
162
233
|
// AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
|
|
@@ -165,7 +236,7 @@ async function writeLoopbackClientInfo(info, issuerUrl) {
|
|
|
165
236
|
// non-macOS platform (no Launch Services plist to query, so this always reports "unknown"
|
|
166
237
|
// there — correctly falling through to the loopback path), takes the lighter direct
|
|
167
238
|
// redirect. ROAMER_MCP_AUTH_FLOW overrides detection entirely — used by this file's own
|
|
168
|
-
// test suite to pin a deterministic path regardless of the CI
|
|
239
|
+
// test suite to pin a deterministic path regardless of the CI runner's real OS/default browser.
|
|
169
240
|
async function detectDefaultBrowser() {
|
|
170
241
|
const override = process.env.ROAMER_MCP_AUTH_FLOW;
|
|
171
242
|
if (override === "device-code") return "com.apple.safari";
|
|
@@ -193,9 +264,11 @@ async function detectDefaultBrowser() {
|
|
|
193
264
|
// user_code — the user would see a different code every single retry, forever, with no
|
|
194
265
|
// path to ever actually finish signing in. Confirmed live 2026-08-21. Persisting the
|
|
195
266
|
// pending flow means a respawned process resumes polling the SAME still-valid code instead.
|
|
196
|
-
async function readPendingFlow() {
|
|
267
|
+
async function readPendingFlow(clientSlug) {
|
|
268
|
+
const path = cacheFilePath("roamer_pending", clientSlug);
|
|
269
|
+
if (!path) return null;
|
|
197
270
|
try {
|
|
198
|
-
const pending = JSON.parse(await readFile(
|
|
271
|
+
const pending = JSON.parse(await readFile(path, "utf8"));
|
|
199
272
|
if (!pending?.device_code || Date.now() > pending.expiresAt) return null;
|
|
200
273
|
return pending;
|
|
201
274
|
} catch {
|
|
@@ -203,15 +276,19 @@ async function readPendingFlow() {
|
|
|
203
276
|
}
|
|
204
277
|
}
|
|
205
278
|
|
|
206
|
-
async function writePendingFlow(device) {
|
|
279
|
+
async function writePendingFlow(clientSlug, device) {
|
|
280
|
+
const path = cacheFilePath("roamer_pending", clientSlug);
|
|
281
|
+
if (!path) return device; // AUTH-51: no usable client identity — never persisted.
|
|
207
282
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
208
283
|
const pending = { ...device, expiresAt: Date.now() + device.expires_in * 1000 };
|
|
209
|
-
await writeFile(
|
|
284
|
+
await writeFile(path, JSON.stringify(pending, null, 2), { mode: 0o600 });
|
|
210
285
|
return pending;
|
|
211
286
|
}
|
|
212
287
|
|
|
213
|
-
function clearPendingFlow() {
|
|
214
|
-
|
|
288
|
+
function clearPendingFlow(clientSlug) {
|
|
289
|
+
const path = cacheFilePath("roamer_pending", clientSlug);
|
|
290
|
+
if (!path) return Promise.resolve();
|
|
291
|
+
return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
|
|
215
292
|
}
|
|
216
293
|
|
|
217
294
|
function expiresSoon(tokens) {
|
|
@@ -271,21 +348,28 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
|
|
|
271
348
|
// their own competing flow — worst case that means multiple device-code dialogs popping up
|
|
272
349
|
// at once, or two refreshes racing on a rotating refresh_token where the loser's retry then
|
|
273
350
|
// forces an unnecessary full sign-in. All concurrent callers await the same in-flight op.
|
|
351
|
+
// AUTH-51: still a single (not per-client) in-flight guard — a given process only ever
|
|
352
|
+
// resolves one clientSlug for its whole lifetime, decided once at startup, so there's never
|
|
353
|
+
// more than one client's flow in flight within a single process anyway.
|
|
274
354
|
let inFlightTokens = null;
|
|
275
355
|
|
|
276
|
-
function getValidTokens(forceRefresh = false) {
|
|
356
|
+
function getValidTokens(clientSlug, forceRefresh = false) {
|
|
277
357
|
if (inFlightTokens) return inFlightTokens;
|
|
278
|
-
inFlightTokens = doGetValidTokens(forceRefresh).finally(() => {
|
|
358
|
+
inFlightTokens = doGetValidTokens(clientSlug, forceRefresh).finally(() => {
|
|
279
359
|
inFlightTokens = null;
|
|
280
360
|
});
|
|
281
361
|
return inFlightTokens;
|
|
282
362
|
}
|
|
283
363
|
|
|
284
|
-
|
|
285
|
-
|
|
364
|
+
// AUTH-51: returns { tokens, reusedSilently } — reusedSilently is true only when no
|
|
365
|
+
// interactive step ran this call (a valid cache hit or a silent refresh-token refresh),
|
|
366
|
+
// which is exactly the case that used to be invisible and is now what triggers
|
|
367
|
+
// notifySessionReused in main().
|
|
368
|
+
async function doGetValidTokens(clientSlug, forceRefresh) {
|
|
369
|
+
let tokens = forceRefresh ? null : await readCachedTokens(clientSlug);
|
|
286
370
|
|
|
287
371
|
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
288
|
-
return tokens;
|
|
372
|
+
return { tokens, reusedSilently: true };
|
|
289
373
|
}
|
|
290
374
|
|
|
291
375
|
if (!forceRefresh && tokens?.refresh_token) {
|
|
@@ -293,23 +377,23 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
293
377
|
log("Refreshing cached token...");
|
|
294
378
|
const fresh = await refreshTokens(tokens.refresh_token);
|
|
295
379
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
296
|
-
await writeCachedTokens(tokens);
|
|
297
|
-
return tokens;
|
|
380
|
+
await writeCachedTokens(clientSlug, tokens);
|
|
381
|
+
return { tokens, reusedSilently: true };
|
|
298
382
|
} catch (err) {
|
|
299
383
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
300
384
|
}
|
|
301
385
|
}
|
|
302
386
|
|
|
303
|
-
if (forceRefresh) await clearCachedTokens();
|
|
387
|
+
if (forceRefresh) await clearCachedTokens(clientSlug);
|
|
304
388
|
|
|
305
389
|
// Resume an already-in-progress flow (from a process this host killed and respawned)
|
|
306
390
|
// instead of minting a new device_code the user would have to start over for.
|
|
307
|
-
let device = await readPendingFlow();
|
|
391
|
+
let device = await readPendingFlow(clientSlug);
|
|
308
392
|
let resuming = Boolean(device);
|
|
309
393
|
if (!resuming) {
|
|
310
394
|
log("Starting sign-in...");
|
|
311
395
|
device = await startDeviceFlow();
|
|
312
|
-
await writePendingFlow(device);
|
|
396
|
+
await writePendingFlow(clientSlug, device);
|
|
313
397
|
} else {
|
|
314
398
|
log("Resuming an already-in-progress sign-in (a prior process was restarted before it finished)...");
|
|
315
399
|
}
|
|
@@ -339,15 +423,15 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
339
423
|
try {
|
|
340
424
|
const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
|
|
341
425
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
342
|
-
await writeCachedTokens(tokens);
|
|
343
|
-
await clearPendingFlow();
|
|
426
|
+
await writeCachedTokens(clientSlug, tokens);
|
|
427
|
+
await clearPendingFlow(clientSlug);
|
|
344
428
|
log("Sign-in complete.");
|
|
345
|
-
return tokens;
|
|
429
|
+
return { tokens, reusedSilently: false };
|
|
346
430
|
} catch (err) {
|
|
347
431
|
// A hard failure (expired/denied, not just this process being killed) means the pending
|
|
348
432
|
// code is genuinely dead — clear it so the next attempt starts a real fresh one instead
|
|
349
433
|
// of retrying a code that will never succeed.
|
|
350
|
-
await clearPendingFlow();
|
|
434
|
+
await clearPendingFlow(clientSlug);
|
|
351
435
|
throw err;
|
|
352
436
|
}
|
|
353
437
|
}
|
|
@@ -358,6 +442,9 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
358
442
|
// stable across runs. A fixed port sidesteps re-registering on every single interactive
|
|
359
443
|
// sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
|
|
360
444
|
// project's original AUTH-11/AUTH-14 mcp-remote-based flow already lived with for months.
|
|
445
|
+
// AUTH-51: this is a pre-existing, unrelated limitation — two different MCP hosts both
|
|
446
|
+
// needing a fresh interactive loopback sign-in at literally the same moment would still
|
|
447
|
+
// race on this one port, same as two runs of the same client already could before this fix.
|
|
361
448
|
const LOOPBACK_PORT = Number(process.env.ROAMER_MCP_OAUTH_PORT ?? 38271);
|
|
362
449
|
const LOOPBACK_REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
|
|
363
450
|
|
|
@@ -415,56 +502,61 @@ async function discoverLoopbackServerInfo() {
|
|
|
415
502
|
// of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
|
|
416
503
|
let inFlightLoopbackTokens = null;
|
|
417
504
|
|
|
418
|
-
function getValidTokensLoopback(forceRefresh = false) {
|
|
505
|
+
function getValidTokensLoopback(clientSlug, forceRefresh = false) {
|
|
419
506
|
if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
|
|
420
|
-
inFlightLoopbackTokens = doGetValidTokensLoopback(forceRefresh).finally(() => {
|
|
507
|
+
inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
|
|
421
508
|
inFlightLoopbackTokens = null;
|
|
422
509
|
});
|
|
423
510
|
return inFlightLoopbackTokens;
|
|
424
511
|
}
|
|
425
512
|
|
|
426
|
-
|
|
513
|
+
// AUTH-51: returns { tokens, reusedSilently } — see doGetValidTokens's comment above, same
|
|
514
|
+
// contract for the loopback mechanism.
|
|
515
|
+
async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
|
|
427
516
|
// AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
|
|
428
517
|
// cached-but-not-yet-expired token from a prior authorization server would otherwise be
|
|
429
518
|
// returned early below without ever learning the issuer changed underneath it.
|
|
430
519
|
const serverInfo = await discoverLoopbackServerInfo();
|
|
431
520
|
const issuerUrl = serverInfo.authorizationServerUrl.toString();
|
|
432
521
|
|
|
433
|
-
let tokens = forceRefresh ? null : await readLoopbackTokens(issuerUrl);
|
|
522
|
+
let tokens = forceRefresh ? null : await readLoopbackTokens(clientSlug, issuerUrl);
|
|
434
523
|
|
|
435
524
|
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
436
|
-
return tokens;
|
|
525
|
+
return { tokens, reusedSilently: true };
|
|
437
526
|
}
|
|
438
527
|
|
|
439
|
-
let clientInformation = await readLoopbackClientInfo(issuerUrl);
|
|
528
|
+
let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
|
|
440
529
|
|
|
441
530
|
if (!forceRefresh && tokens?.refresh_token && clientInformation) {
|
|
442
531
|
try {
|
|
443
532
|
log("Refreshing cached token...");
|
|
444
533
|
const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
|
|
445
534
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
446
|
-
await writeLoopbackTokens(tokens, issuerUrl);
|
|
447
|
-
return tokens;
|
|
535
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
|
|
536
|
+
return { tokens, reusedSilently: true };
|
|
448
537
|
} catch (err) {
|
|
449
538
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
450
539
|
}
|
|
451
540
|
}
|
|
452
541
|
|
|
453
|
-
if (forceRefresh) await clearLoopbackTokens();
|
|
542
|
+
if (forceRefresh) await clearLoopbackTokens(clientSlug);
|
|
454
543
|
|
|
455
544
|
if (!clientInformation) {
|
|
456
545
|
log("Registering as a new OAuth client...");
|
|
546
|
+
// AUTH-51: the registered client_name carries the connecting client's own identity when
|
|
547
|
+
// known, so a future server-side session list/audit view could actually tell clients
|
|
548
|
+
// apart instead of seeing the same generic name for every stdio-bridge user.
|
|
457
549
|
clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
|
|
458
550
|
metadata: serverInfo.authorizationServerMetadata,
|
|
459
551
|
clientMetadata: {
|
|
460
|
-
client_name: "Roamer MCP (stdio bridge)",
|
|
552
|
+
client_name: clientSlug ? `Roamer MCP (stdio bridge — ${clientSlug})` : "Roamer MCP (stdio bridge)",
|
|
461
553
|
redirect_uris: [LOOPBACK_REDIRECT_URI],
|
|
462
554
|
grant_types: ["authorization_code", "refresh_token"],
|
|
463
555
|
response_types: ["code"],
|
|
464
556
|
token_endpoint_auth_method: "none",
|
|
465
557
|
},
|
|
466
558
|
});
|
|
467
|
-
await writeLoopbackClientInfo(clientInformation, issuerUrl);
|
|
559
|
+
await writeLoopbackClientInfo(clientSlug, clientInformation, issuerUrl);
|
|
468
560
|
}
|
|
469
561
|
|
|
470
562
|
log("Starting sign-in...");
|
|
@@ -517,13 +609,16 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
517
609
|
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
518
610
|
});
|
|
519
611
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
520
|
-
await writeLoopbackTokens(tokens, issuerUrl);
|
|
612
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
|
|
521
613
|
log("Sign-in complete.");
|
|
522
|
-
return tokens;
|
|
614
|
+
return { tokens, reusedSilently: false };
|
|
523
615
|
}
|
|
524
616
|
|
|
525
617
|
// AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
|
|
526
618
|
// queued while sign-in was still in progress, with identical forwarding/retry behavior.
|
|
619
|
+
// AUTH-51: unchanged — takes a plain getFreshTokens(forceRefresh) => Promise<tokens>
|
|
620
|
+
// function; main() adapts the new { tokens, reusedSilently }-returning, clientSlug-aware
|
|
621
|
+
// functions above into that shape rather than changing this function's own contract.
|
|
527
622
|
async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens) {
|
|
528
623
|
try {
|
|
529
624
|
let tokens = getTokens();
|
|
@@ -577,6 +672,15 @@ async function main() {
|
|
|
577
672
|
let ready = false;
|
|
578
673
|
const pendingLines = [];
|
|
579
674
|
|
|
675
|
+
// AUTH-51: which client is asking, resolved once from the first stdin line (the MCP
|
|
676
|
+
// `initialize` request, always message #1 by protocol) before any credential is touched.
|
|
677
|
+
let clientSlug = null;
|
|
678
|
+
let slugResolved = false;
|
|
679
|
+
let resolveFirstLine;
|
|
680
|
+
const firstLine = new Promise((resolve) => {
|
|
681
|
+
resolveFirstLine = resolve;
|
|
682
|
+
});
|
|
683
|
+
|
|
580
684
|
const getTokens = () => tokens;
|
|
581
685
|
const setTokens = (fresh) => {
|
|
582
686
|
tokens = fresh;
|
|
@@ -586,8 +690,9 @@ async function main() {
|
|
|
586
690
|
// only consumer) never runs before `ready` flips true, which itself never happens before
|
|
587
691
|
// these are set, so deferring the assignment past the listener attach is safe and keeps
|
|
588
692
|
// AUTH-28's guarantee (listener attaches before any await) intact.
|
|
589
|
-
let getFreshTokens;
|
|
590
|
-
let clearFreshTokens;
|
|
693
|
+
let getFreshTokens; // (clientSlug, forceRefresh) => Promise<{ tokens, reusedSilently }>
|
|
694
|
+
let clearFreshTokens; // () => Promise<void>
|
|
695
|
+
let freshTokensForForward; // (forceRefresh) => Promise<tokens> — forwardLine's plain-tokens contract
|
|
591
696
|
|
|
592
697
|
let buffer = "";
|
|
593
698
|
process.stdin.setEncoding("utf8");
|
|
@@ -598,8 +703,13 @@ async function main() {
|
|
|
598
703
|
const line = buffer.slice(0, newlineIndex);
|
|
599
704
|
buffer = buffer.slice(newlineIndex + 1);
|
|
600
705
|
if (!line.trim()) continue;
|
|
706
|
+
if (!slugResolved) {
|
|
707
|
+
slugResolved = true;
|
|
708
|
+
clientSlug = clientSlugFromInitializeLine(line);
|
|
709
|
+
resolveFirstLine();
|
|
710
|
+
}
|
|
601
711
|
if (ready) {
|
|
602
|
-
forwardLine(transport, line, getTokens, setTokens,
|
|
712
|
+
forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
|
|
603
713
|
} else {
|
|
604
714
|
pendingLines.push(line);
|
|
605
715
|
}
|
|
@@ -614,16 +724,29 @@ async function main() {
|
|
|
614
724
|
|
|
615
725
|
log("Local STDIO proxy running. Press Ctrl+C to exit.");
|
|
616
726
|
|
|
727
|
+
// AUTH-51: wait for that first line before authenticating, so the credential this process
|
|
728
|
+
// obtains is scoped to the right client. Safe alongside AUTH-28's no-silence guarantee —
|
|
729
|
+
// the listener above is already attached and queuing by this point, and a well-behaved
|
|
730
|
+
// host sends `initialize` immediately on spawn with nothing required from this process
|
|
731
|
+
// first, so this wait adds no meaningful latency and never reintroduces the
|
|
732
|
+
// respond-before-timeout gap AUTH-28 fixed.
|
|
733
|
+
await firstLine;
|
|
734
|
+
log(clientSlug ? `Client identified as "${clientSlug}".` : "No usable client identity sent — this sign-in will not be cached for reuse.");
|
|
735
|
+
|
|
617
736
|
// AUTH-14: decided once per process — detectDefaultBrowser() shells out to Launch
|
|
618
737
|
// Services, no need to re-check mid-session. Safari-default machines keep using the
|
|
619
738
|
// existing device-code mechanism (AUTH-25); everything else uses the lighter loopback
|
|
620
739
|
// redirect, including refreshes and reactive re-auth below.
|
|
621
740
|
const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
|
|
622
741
|
getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
623
|
-
clearFreshTokens = isSafari ? clearCachedTokens : clearLoopbackTokens;
|
|
742
|
+
clearFreshTokens = () => (isSafari ? clearCachedTokens(clientSlug) : clearLoopbackTokens(clientSlug));
|
|
743
|
+
freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
|
|
624
744
|
|
|
745
|
+
let reusedSilently = false;
|
|
625
746
|
try {
|
|
626
|
-
|
|
747
|
+
const result = await getFreshTokens(clientSlug);
|
|
748
|
+
tokens = result.tokens;
|
|
749
|
+
reusedSilently = result.reusedSilently;
|
|
627
750
|
} catch (err) {
|
|
628
751
|
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
629
752
|
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
@@ -633,6 +756,10 @@ async function main() {
|
|
|
633
756
|
process.exit(1);
|
|
634
757
|
}
|
|
635
758
|
|
|
759
|
+
// AUTH-51: the moment that used to be completely silent — a process starting up and
|
|
760
|
+
// immediately using a credential it never interactively obtained this run.
|
|
761
|
+
if (reusedSilently) await notifySessionReused(clientSlug);
|
|
762
|
+
|
|
636
763
|
transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
|
|
637
764
|
requestInit: {
|
|
638
765
|
get headers() {
|
|
@@ -660,7 +787,7 @@ async function main() {
|
|
|
660
787
|
|
|
661
788
|
ready = true;
|
|
662
789
|
for (const line of pendingLines) {
|
|
663
|
-
forwardLine(transport, line, getTokens, setTokens,
|
|
790
|
+
forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
|
|
664
791
|
}
|
|
665
792
|
}
|
|
666
793
|
|
|
@@ -681,6 +808,12 @@ export {
|
|
|
681
808
|
respondWithSignInError,
|
|
682
809
|
forwardLine,
|
|
683
810
|
detectDefaultBrowser,
|
|
811
|
+
clientSlugFromInitializeLine,
|
|
812
|
+
readCachedTokens,
|
|
813
|
+
writeCachedTokens,
|
|
814
|
+
clearCachedTokens,
|
|
815
|
+
readPendingFlow,
|
|
816
|
+
writePendingFlow,
|
|
684
817
|
readLoopbackTokens,
|
|
685
818
|
writeLoopbackTokens,
|
|
686
819
|
readLoopbackClientInfo,
|