@mathismeadows/roamer-device-auth 1.3.1 → 1.4.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 +1 -1
- package/roamer-device-auth.mjs +207 -67
package/package.json
CHANGED
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}.`;
|
|
138
|
+
try {
|
|
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;
|
|
95
152
|
try {
|
|
96
|
-
const tokens = JSON.parse(await readFile(
|
|
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) {
|
|
@@ -235,8 +312,15 @@ async function refreshTokens(refreshToken) {
|
|
|
235
312
|
return response.json();
|
|
236
313
|
}
|
|
237
314
|
|
|
238
|
-
|
|
239
|
-
|
|
315
|
+
// AUTH-52: carries the same client identity the loopback flow already sends at DCR
|
|
316
|
+
// registration, so the server's device-code confirm/success/failure/contact-email pages can
|
|
317
|
+
// name which client each sign-in prompt is actually for, instead of a generic message.
|
|
318
|
+
async function startDeviceFlow(clientSlug) {
|
|
319
|
+
const response = await fetch(`${ROAMER_MCP_ORIGIN}/oauth/device/start`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: { "Content-Type": "application/json" },
|
|
322
|
+
body: JSON.stringify({ client_name: clientSlug }),
|
|
323
|
+
});
|
|
240
324
|
if (!response.ok) {
|
|
241
325
|
throw new Error(`Device flow start failed: ${response.status} ${await response.text()}`);
|
|
242
326
|
}
|
|
@@ -271,21 +355,28 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
|
|
|
271
355
|
// their own competing flow — worst case that means multiple device-code dialogs popping up
|
|
272
356
|
// at once, or two refreshes racing on a rotating refresh_token where the loser's retry then
|
|
273
357
|
// forces an unnecessary full sign-in. All concurrent callers await the same in-flight op.
|
|
358
|
+
// AUTH-51: still a single (not per-client) in-flight guard — a given process only ever
|
|
359
|
+
// resolves one clientSlug for its whole lifetime, decided once at startup, so there's never
|
|
360
|
+
// more than one client's flow in flight within a single process anyway.
|
|
274
361
|
let inFlightTokens = null;
|
|
275
362
|
|
|
276
|
-
function getValidTokens(forceRefresh = false) {
|
|
363
|
+
function getValidTokens(clientSlug, forceRefresh = false) {
|
|
277
364
|
if (inFlightTokens) return inFlightTokens;
|
|
278
|
-
inFlightTokens = doGetValidTokens(forceRefresh).finally(() => {
|
|
365
|
+
inFlightTokens = doGetValidTokens(clientSlug, forceRefresh).finally(() => {
|
|
279
366
|
inFlightTokens = null;
|
|
280
367
|
});
|
|
281
368
|
return inFlightTokens;
|
|
282
369
|
}
|
|
283
370
|
|
|
284
|
-
|
|
285
|
-
|
|
371
|
+
// AUTH-51: returns { tokens, reusedSilently } — reusedSilently is true only when no
|
|
372
|
+
// interactive step ran this call (a valid cache hit or a silent refresh-token refresh),
|
|
373
|
+
// which is exactly the case that used to be invisible and is now what triggers
|
|
374
|
+
// notifySessionReused in main().
|
|
375
|
+
async function doGetValidTokens(clientSlug, forceRefresh) {
|
|
376
|
+
let tokens = forceRefresh ? null : await readCachedTokens(clientSlug);
|
|
286
377
|
|
|
287
378
|
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
288
|
-
return tokens;
|
|
379
|
+
return { tokens, reusedSilently: true };
|
|
289
380
|
}
|
|
290
381
|
|
|
291
382
|
if (!forceRefresh && tokens?.refresh_token) {
|
|
@@ -293,23 +384,23 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
293
384
|
log("Refreshing cached token...");
|
|
294
385
|
const fresh = await refreshTokens(tokens.refresh_token);
|
|
295
386
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
296
|
-
await writeCachedTokens(tokens);
|
|
297
|
-
return tokens;
|
|
387
|
+
await writeCachedTokens(clientSlug, tokens);
|
|
388
|
+
return { tokens, reusedSilently: true };
|
|
298
389
|
} catch (err) {
|
|
299
390
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
300
391
|
}
|
|
301
392
|
}
|
|
302
393
|
|
|
303
|
-
if (forceRefresh) await clearCachedTokens();
|
|
394
|
+
if (forceRefresh) await clearCachedTokens(clientSlug);
|
|
304
395
|
|
|
305
396
|
// Resume an already-in-progress flow (from a process this host killed and respawned)
|
|
306
397
|
// instead of minting a new device_code the user would have to start over for.
|
|
307
|
-
let device = await readPendingFlow();
|
|
398
|
+
let device = await readPendingFlow(clientSlug);
|
|
308
399
|
let resuming = Boolean(device);
|
|
309
400
|
if (!resuming) {
|
|
310
401
|
log("Starting sign-in...");
|
|
311
|
-
device = await startDeviceFlow();
|
|
312
|
-
await writePendingFlow(device);
|
|
402
|
+
device = await startDeviceFlow(clientSlug);
|
|
403
|
+
await writePendingFlow(clientSlug, device);
|
|
313
404
|
} else {
|
|
314
405
|
log("Resuming an already-in-progress sign-in (a prior process was restarted before it finished)...");
|
|
315
406
|
}
|
|
@@ -339,15 +430,15 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
339
430
|
try {
|
|
340
431
|
const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
|
|
341
432
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
342
|
-
await writeCachedTokens(tokens);
|
|
343
|
-
await clearPendingFlow();
|
|
433
|
+
await writeCachedTokens(clientSlug, tokens);
|
|
434
|
+
await clearPendingFlow(clientSlug);
|
|
344
435
|
log("Sign-in complete.");
|
|
345
|
-
return tokens;
|
|
436
|
+
return { tokens, reusedSilently: false };
|
|
346
437
|
} catch (err) {
|
|
347
438
|
// A hard failure (expired/denied, not just this process being killed) means the pending
|
|
348
439
|
// code is genuinely dead — clear it so the next attempt starts a real fresh one instead
|
|
349
440
|
// of retrying a code that will never succeed.
|
|
350
|
-
await clearPendingFlow();
|
|
441
|
+
await clearPendingFlow(clientSlug);
|
|
351
442
|
throw err;
|
|
352
443
|
}
|
|
353
444
|
}
|
|
@@ -358,6 +449,9 @@ async function doGetValidTokens(forceRefresh) {
|
|
|
358
449
|
// stable across runs. A fixed port sidesteps re-registering on every single interactive
|
|
359
450
|
// sign-in; the tradeoff (a port-in-use conflict is possible, if rare) is the same one this
|
|
360
451
|
// project's original AUTH-11/AUTH-14 mcp-remote-based flow already lived with for months.
|
|
452
|
+
// AUTH-51: this is a pre-existing, unrelated limitation — two different MCP hosts both
|
|
453
|
+
// needing a fresh interactive loopback sign-in at literally the same moment would still
|
|
454
|
+
// race on this one port, same as two runs of the same client already could before this fix.
|
|
361
455
|
const LOOPBACK_PORT = Number(process.env.ROAMER_MCP_OAUTH_PORT ?? 38271);
|
|
362
456
|
const LOOPBACK_REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
|
|
363
457
|
|
|
@@ -415,56 +509,61 @@ async function discoverLoopbackServerInfo() {
|
|
|
415
509
|
// of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
|
|
416
510
|
let inFlightLoopbackTokens = null;
|
|
417
511
|
|
|
418
|
-
function getValidTokensLoopback(forceRefresh = false) {
|
|
512
|
+
function getValidTokensLoopback(clientSlug, forceRefresh = false) {
|
|
419
513
|
if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
|
|
420
|
-
inFlightLoopbackTokens = doGetValidTokensLoopback(forceRefresh).finally(() => {
|
|
514
|
+
inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
|
|
421
515
|
inFlightLoopbackTokens = null;
|
|
422
516
|
});
|
|
423
517
|
return inFlightLoopbackTokens;
|
|
424
518
|
}
|
|
425
519
|
|
|
426
|
-
|
|
520
|
+
// AUTH-51: returns { tokens, reusedSilently } — see doGetValidTokens's comment above, same
|
|
521
|
+
// contract for the loopback mechanism.
|
|
522
|
+
async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
|
|
427
523
|
// AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
|
|
428
524
|
// cached-but-not-yet-expired token from a prior authorization server would otherwise be
|
|
429
525
|
// returned early below without ever learning the issuer changed underneath it.
|
|
430
526
|
const serverInfo = await discoverLoopbackServerInfo();
|
|
431
527
|
const issuerUrl = serverInfo.authorizationServerUrl.toString();
|
|
432
528
|
|
|
433
|
-
let tokens = forceRefresh ? null : await readLoopbackTokens(issuerUrl);
|
|
529
|
+
let tokens = forceRefresh ? null : await readLoopbackTokens(clientSlug, issuerUrl);
|
|
434
530
|
|
|
435
531
|
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
436
|
-
return tokens;
|
|
532
|
+
return { tokens, reusedSilently: true };
|
|
437
533
|
}
|
|
438
534
|
|
|
439
|
-
let clientInformation = await readLoopbackClientInfo(issuerUrl);
|
|
535
|
+
let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
|
|
440
536
|
|
|
441
537
|
if (!forceRefresh && tokens?.refresh_token && clientInformation) {
|
|
442
538
|
try {
|
|
443
539
|
log("Refreshing cached token...");
|
|
444
540
|
const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
|
|
445
541
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
446
|
-
await writeLoopbackTokens(tokens, issuerUrl);
|
|
447
|
-
return tokens;
|
|
542
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
|
|
543
|
+
return { tokens, reusedSilently: true };
|
|
448
544
|
} catch (err) {
|
|
449
545
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
450
546
|
}
|
|
451
547
|
}
|
|
452
548
|
|
|
453
|
-
if (forceRefresh) await clearLoopbackTokens();
|
|
549
|
+
if (forceRefresh) await clearLoopbackTokens(clientSlug);
|
|
454
550
|
|
|
455
551
|
if (!clientInformation) {
|
|
456
552
|
log("Registering as a new OAuth client...");
|
|
553
|
+
// AUTH-51: the registered client_name carries the connecting client's own identity when
|
|
554
|
+
// known, so a future server-side session list/audit view could actually tell clients
|
|
555
|
+
// apart instead of seeing the same generic name for every stdio-bridge user.
|
|
457
556
|
clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
|
|
458
557
|
metadata: serverInfo.authorizationServerMetadata,
|
|
459
558
|
clientMetadata: {
|
|
460
|
-
client_name: "Roamer MCP (stdio bridge)",
|
|
559
|
+
client_name: clientSlug ? `Roamer MCP (stdio bridge — ${clientSlug})` : "Roamer MCP (stdio bridge)",
|
|
461
560
|
redirect_uris: [LOOPBACK_REDIRECT_URI],
|
|
462
561
|
grant_types: ["authorization_code", "refresh_token"],
|
|
463
562
|
response_types: ["code"],
|
|
464
563
|
token_endpoint_auth_method: "none",
|
|
465
564
|
},
|
|
466
565
|
});
|
|
467
|
-
await writeLoopbackClientInfo(clientInformation, issuerUrl);
|
|
566
|
+
await writeLoopbackClientInfo(clientSlug, clientInformation, issuerUrl);
|
|
468
567
|
}
|
|
469
568
|
|
|
470
569
|
log("Starting sign-in...");
|
|
@@ -517,13 +616,16 @@ async function doGetValidTokensLoopback(forceRefresh) {
|
|
|
517
616
|
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
518
617
|
});
|
|
519
618
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
520
|
-
await writeLoopbackTokens(tokens, issuerUrl);
|
|
619
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
|
|
521
620
|
log("Sign-in complete.");
|
|
522
|
-
return tokens;
|
|
621
|
+
return { tokens, reusedSilently: false };
|
|
523
622
|
}
|
|
524
623
|
|
|
525
624
|
// AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
|
|
526
625
|
// queued while sign-in was still in progress, with identical forwarding/retry behavior.
|
|
626
|
+
// AUTH-51: unchanged — takes a plain getFreshTokens(forceRefresh) => Promise<tokens>
|
|
627
|
+
// function; main() adapts the new { tokens, reusedSilently }-returning, clientSlug-aware
|
|
628
|
+
// functions above into that shape rather than changing this function's own contract.
|
|
527
629
|
async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens) {
|
|
528
630
|
try {
|
|
529
631
|
let tokens = getTokens();
|
|
@@ -577,6 +679,15 @@ async function main() {
|
|
|
577
679
|
let ready = false;
|
|
578
680
|
const pendingLines = [];
|
|
579
681
|
|
|
682
|
+
// AUTH-51: which client is asking, resolved once from the first stdin line (the MCP
|
|
683
|
+
// `initialize` request, always message #1 by protocol) before any credential is touched.
|
|
684
|
+
let clientSlug = null;
|
|
685
|
+
let slugResolved = false;
|
|
686
|
+
let resolveFirstLine;
|
|
687
|
+
const firstLine = new Promise((resolve) => {
|
|
688
|
+
resolveFirstLine = resolve;
|
|
689
|
+
});
|
|
690
|
+
|
|
580
691
|
const getTokens = () => tokens;
|
|
581
692
|
const setTokens = (fresh) => {
|
|
582
693
|
tokens = fresh;
|
|
@@ -586,8 +697,9 @@ async function main() {
|
|
|
586
697
|
// only consumer) never runs before `ready` flips true, which itself never happens before
|
|
587
698
|
// these are set, so deferring the assignment past the listener attach is safe and keeps
|
|
588
699
|
// AUTH-28's guarantee (listener attaches before any await) intact.
|
|
589
|
-
let getFreshTokens;
|
|
590
|
-
let clearFreshTokens;
|
|
700
|
+
let getFreshTokens; // (clientSlug, forceRefresh) => Promise<{ tokens, reusedSilently }>
|
|
701
|
+
let clearFreshTokens; // () => Promise<void>
|
|
702
|
+
let freshTokensForForward; // (forceRefresh) => Promise<tokens> — forwardLine's plain-tokens contract
|
|
591
703
|
|
|
592
704
|
let buffer = "";
|
|
593
705
|
process.stdin.setEncoding("utf8");
|
|
@@ -598,8 +710,13 @@ async function main() {
|
|
|
598
710
|
const line = buffer.slice(0, newlineIndex);
|
|
599
711
|
buffer = buffer.slice(newlineIndex + 1);
|
|
600
712
|
if (!line.trim()) continue;
|
|
713
|
+
if (!slugResolved) {
|
|
714
|
+
slugResolved = true;
|
|
715
|
+
clientSlug = clientSlugFromInitializeLine(line);
|
|
716
|
+
resolveFirstLine();
|
|
717
|
+
}
|
|
601
718
|
if (ready) {
|
|
602
|
-
forwardLine(transport, line, getTokens, setTokens,
|
|
719
|
+
forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
|
|
603
720
|
} else {
|
|
604
721
|
pendingLines.push(line);
|
|
605
722
|
}
|
|
@@ -614,16 +731,29 @@ async function main() {
|
|
|
614
731
|
|
|
615
732
|
log("Local STDIO proxy running. Press Ctrl+C to exit.");
|
|
616
733
|
|
|
734
|
+
// AUTH-51: wait for that first line before authenticating, so the credential this process
|
|
735
|
+
// obtains is scoped to the right client. Safe alongside AUTH-28's no-silence guarantee —
|
|
736
|
+
// the listener above is already attached and queuing by this point, and a well-behaved
|
|
737
|
+
// host sends `initialize` immediately on spawn with nothing required from this process
|
|
738
|
+
// first, so this wait adds no meaningful latency and never reintroduces the
|
|
739
|
+
// respond-before-timeout gap AUTH-28 fixed.
|
|
740
|
+
await firstLine;
|
|
741
|
+
log(clientSlug ? `Client identified as "${clientSlug}".` : "No usable client identity sent — this sign-in will not be cached for reuse.");
|
|
742
|
+
|
|
617
743
|
// AUTH-14: decided once per process — detectDefaultBrowser() shells out to Launch
|
|
618
744
|
// Services, no need to re-check mid-session. Safari-default machines keep using the
|
|
619
745
|
// existing device-code mechanism (AUTH-25); everything else uses the lighter loopback
|
|
620
746
|
// redirect, including refreshes and reactive re-auth below.
|
|
621
747
|
const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
|
|
622
748
|
getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
623
|
-
clearFreshTokens = isSafari ? clearCachedTokens : clearLoopbackTokens;
|
|
749
|
+
clearFreshTokens = () => (isSafari ? clearCachedTokens(clientSlug) : clearLoopbackTokens(clientSlug));
|
|
750
|
+
freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
|
|
624
751
|
|
|
752
|
+
let reusedSilently = false;
|
|
625
753
|
try {
|
|
626
|
-
|
|
754
|
+
const result = await getFreshTokens(clientSlug);
|
|
755
|
+
tokens = result.tokens;
|
|
756
|
+
reusedSilently = result.reusedSilently;
|
|
627
757
|
} catch (err) {
|
|
628
758
|
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
629
759
|
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
@@ -633,6 +763,10 @@ async function main() {
|
|
|
633
763
|
process.exit(1);
|
|
634
764
|
}
|
|
635
765
|
|
|
766
|
+
// AUTH-51: the moment that used to be completely silent — a process starting up and
|
|
767
|
+
// immediately using a credential it never interactively obtained this run.
|
|
768
|
+
if (reusedSilently) await notifySessionReused(clientSlug);
|
|
769
|
+
|
|
636
770
|
transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
|
|
637
771
|
requestInit: {
|
|
638
772
|
get headers() {
|
|
@@ -660,7 +794,7 @@ async function main() {
|
|
|
660
794
|
|
|
661
795
|
ready = true;
|
|
662
796
|
for (const line of pendingLines) {
|
|
663
|
-
forwardLine(transport, line, getTokens, setTokens,
|
|
797
|
+
forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
|
|
664
798
|
}
|
|
665
799
|
}
|
|
666
800
|
|
|
@@ -681,6 +815,12 @@ export {
|
|
|
681
815
|
respondWithSignInError,
|
|
682
816
|
forwardLine,
|
|
683
817
|
detectDefaultBrowser,
|
|
818
|
+
clientSlugFromInitializeLine,
|
|
819
|
+
readCachedTokens,
|
|
820
|
+
writeCachedTokens,
|
|
821
|
+
clearCachedTokens,
|
|
822
|
+
readPendingFlow,
|
|
823
|
+
writePendingFlow,
|
|
684
824
|
readLoopbackTokens,
|
|
685
825
|
writeLoopbackTokens,
|
|
686
826
|
readLoopbackClientInfo,
|