@mathismeadows/roamer-device-auth 1.4.2 → 1.5.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 +413 -58
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathismeadows/roamer-device-auth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"mcpName": "com.mathismeadows/roamer-mcp",
|
|
6
6
|
"type": "module",
|
|
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
|
+
"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). AUTH-54/55/57/58: sessions are cached per (client, identity) pair, with explicit login/logout/status subcommands to switch or inspect which identity a client is using.",
|
|
8
8
|
"bin": {
|
|
9
9
|
"roamer-device-auth": "./roamer-device-auth.mjs"
|
|
10
10
|
},
|
package/roamer-device-auth.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
exchangeAuthorization,
|
|
44
44
|
refreshAuthorization,
|
|
45
45
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
46
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
46
|
+
import { readFile, writeFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
47
47
|
import { realpathSync } from "node:fs";
|
|
48
48
|
import { homedir } from "node:os";
|
|
49
49
|
import { join } from "node:path";
|
|
@@ -66,9 +66,141 @@ const CACHE_DIR = join(homedir(), ".mcp-auth-device");
|
|
|
66
66
|
// interactive sign-in on every single invocation instead of falling into any shared
|
|
67
67
|
// catch-all bucket, which would just recreate the bug this item fixes for a smaller
|
|
68
68
|
// population of clients.
|
|
69
|
-
|
|
69
|
+
//
|
|
70
|
+
// AUTH-54: an optional third segment, identityKey, namespaces the token caches further by
|
|
71
|
+
// WHICH PERSON authenticated, not just which app is connecting — AUTH-51 alone still let a
|
|
72
|
+
// second identity on the same client silently reuse or shadow the first one's cache, since
|
|
73
|
+
// identity wasn't part of the key at all. Only the actual token caches take an identityKey
|
|
74
|
+
// (see call sites below); roamer_loopback_client (DCR registration) and roamer_pending
|
|
75
|
+
// (in-progress device-code flow) are deliberately left clientSlug-only — a registered OAuth
|
|
76
|
+
// client and an in-flight sign-in aren't tied to any one person yet, and folding an identity
|
|
77
|
+
// segment into them would just be dead complexity. `__` is reserved as this function's own
|
|
78
|
+
// delimiter: identityKeyFromTokens below never emits one, so splitting on it is unambiguous.
|
|
79
|
+
function cacheFilePath(baseName, clientSlug, identityKey = null) {
|
|
70
80
|
if (!clientSlug) return null;
|
|
71
|
-
|
|
81
|
+
const suffix = identityKey ? `__${identityKey}` : "";
|
|
82
|
+
return join(CACHE_DIR, `${baseName}__${clientSlug}${suffix}.json`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// AUTH-54: derives a stable, filesystem-safe identity key from an access token's own claims.
|
|
86
|
+
// Prefers oid deliberately (over identityLabelFromTokens' email-first choice below) — oid is
|
|
87
|
+
// durable and never changes for a given person, where email/preferred_username can (a verified
|
|
88
|
+
// contact email update, a UPN change). Keying the cache FILE by something that can change would
|
|
89
|
+
// silently orphan an existing cache entry and grow a spurious "new identity" next to it, which
|
|
90
|
+
// is exactly the confusion this item exists to prevent. No signature verification here — this
|
|
91
|
+
// is purely a local cache-partitioning key, not a trust decision; the server has already
|
|
92
|
+
// validated the token by the time this process holds it.
|
|
93
|
+
function identityKeyFromTokens(tokens) {
|
|
94
|
+
const raw = decodeTokenPayload(tokens)?.oid ?? identityLabelFromTokens(tokens);
|
|
95
|
+
if (!raw) return null;
|
|
96
|
+
return String(raw)
|
|
97
|
+
.trim()
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
// Deliberately excludes "_" (unlike clientSlugFromInitializeLine's slug sanitizer) so "__"
|
|
100
|
+
// stays a delimiter cacheFilePath can split on unambiguously.
|
|
101
|
+
.replace(/[^a-z0-9.@-]+/g, "-")
|
|
102
|
+
.replace(/^-+|-+$/g, "") || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// AUTH-54/56: the human-readable form (email/preferred_username first, falling back to the raw
|
|
106
|
+
// oid) used for the "status" listing and the AUTH-56 stderr line — deliberately independent of
|
|
107
|
+
// identityKeyFromTokens' own oid-first priority above: a key optimizes for stability, a label
|
|
108
|
+
// optimizes for being recognizable to the person reading it.
|
|
109
|
+
function identityLabelFromTokens(tokens) {
|
|
110
|
+
const payload = decodeTokenPayload(tokens);
|
|
111
|
+
return payload?.email ?? payload?.preferred_username ?? payload?.oid ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function decodeTokenPayload(tokens) {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(Buffer.from(tokens.access_token.split(".")[1], "base64url").toString("utf8"));
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// AUTH-54: which identity a client slug's NORMAL (non-login/logout/status) runs should use.
|
|
123
|
+
// This is the piece that makes identity-aware caching invisible to the common single-identity
|
|
124
|
+
// case — the first-ever sign-in for a client writes both the token cache AND this pointer, so
|
|
125
|
+
// every later ordinary run finds it automatically with no config change. It only changes when
|
|
126
|
+
// an explicit `login` establishes a new one, or `logout` clears it back to "none" (AUTH-57).
|
|
127
|
+
async function readActiveIdentity(clientSlug) {
|
|
128
|
+
const path = cacheFilePath("roamer_active", clientSlug);
|
|
129
|
+
if (!path) return null;
|
|
130
|
+
try {
|
|
131
|
+
const active = JSON.parse(await readFile(path, "utf8"));
|
|
132
|
+
return active?.identityKey ? active : null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function writeActiveIdentity(clientSlug, { identityKey, label }) {
|
|
139
|
+
const path = cacheFilePath("roamer_active", clientSlug);
|
|
140
|
+
if (!path) return;
|
|
141
|
+
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
142
|
+
await writeFile(path, JSON.stringify({ identityKey, label }, null, 2), { mode: 0o600 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clearActiveIdentity(clientSlug) {
|
|
146
|
+
const path = cacheFilePath("roamer_active", clientSlug);
|
|
147
|
+
if (!path) return Promise.resolve();
|
|
148
|
+
return unlink(path).catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// AUTH-58: lists every identity currently cached for a client slug, across both auth
|
|
152
|
+
// mechanisms (a machine's default browser — and therefore which mechanism is in play — can
|
|
153
|
+
// change over time; a person could have identities cached under either). Reads each file's own
|
|
154
|
+
// stamped label rather than keeping a separate index, so this can never drift from what's
|
|
155
|
+
// actually on disk — the same self-describing-file approach CACHE_VERSION already uses.
|
|
156
|
+
async function listCachedIdentities(clientSlug) {
|
|
157
|
+
const active = await readActiveIdentity(clientSlug);
|
|
158
|
+
const prefixes = [`roamer_tokens__${clientSlug}__`, `roamer_loopback_tokens__${clientSlug}__`];
|
|
159
|
+
const seen = new Map(); // identityKey -> label, deduped across the two mechanisms
|
|
160
|
+
let entries;
|
|
161
|
+
try {
|
|
162
|
+
entries = await readdir(CACHE_DIR);
|
|
163
|
+
} catch {
|
|
164
|
+
entries = [];
|
|
165
|
+
}
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
const prefix = prefixes.find((p) => entry.startsWith(p));
|
|
168
|
+
if (!prefix || !entry.endsWith(".json")) continue;
|
|
169
|
+
const identityKey = entry.slice(prefix.length, -".json".length);
|
|
170
|
+
if (!identityKey || seen.has(identityKey)) continue;
|
|
171
|
+
try {
|
|
172
|
+
const contents = JSON.parse(await readFile(join(CACHE_DIR, entry), "utf8"));
|
|
173
|
+
seen.set(identityKey, contents?.label ?? identityKey);
|
|
174
|
+
} catch {
|
|
175
|
+
seen.set(identityKey, identityKey);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return [...seen.entries()].map(([identityKey, label]) => ({
|
|
179
|
+
identityKey,
|
|
180
|
+
label,
|
|
181
|
+
active: identityKey === active?.identityKey,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// AUTH-55/57/58: login/logout/status run standalone, with no MCP `initialize` handshake to
|
|
186
|
+
// derive a client slug from the way normal bridge operation does — so unlike every other
|
|
187
|
+
// function in this file, they need to discover what slugs even exist on disk, to guide someone
|
|
188
|
+
// who omitted --client (or got it wrong) rather than just failing opaquely.
|
|
189
|
+
async function listKnownClientSlugs() {
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = await readdir(CACHE_DIR);
|
|
193
|
+
} catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
const slugs = new Set();
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
// Safe to split on "__" without ambiguity: neither a client slug (clientSlugFromInitializeLine)
|
|
199
|
+
// nor an identity key (identityKeyFromTokens) can contain an underscore post-sanitization.
|
|
200
|
+
const match = entry.match(/^roamer_(?:tokens|loopback_tokens)__([^_]+)__/);
|
|
201
|
+
if (match) slugs.add(match[1]);
|
|
202
|
+
}
|
|
203
|
+
return [...slugs].sort();
|
|
72
204
|
}
|
|
73
205
|
|
|
74
206
|
// Bumped whenever the cached shape changes meaningfully. A cache written by a prior
|
|
@@ -96,6 +228,13 @@ function isAuthError(err) {
|
|
|
96
228
|
// missing/blank name just means "no usable identity", never a thrown error — a
|
|
97
229
|
// non-compliant host must degrade gracefully (an uncached, always-fresh sign-in), not crash
|
|
98
230
|
// the bridge.
|
|
231
|
+
//
|
|
232
|
+
// AUTH-54: "_" was dropped from the allowed character set (it previously joined "-" as
|
|
233
|
+
// filename-safe) so a client slug can never itself contain "__" — that sequence is now
|
|
234
|
+
// cacheFilePath's own reserved delimiter between the client-slug and identity-key segments of
|
|
235
|
+
// a filename, and listKnownClientSlugs/listCachedIdentities parse filenames back apart on
|
|
236
|
+
// exactly that assumption. No real MCP client name has ever produced an underscore here in
|
|
237
|
+
// practice; this only changes behavior for a hypothetical clientInfo.name containing one.
|
|
99
238
|
function clientSlugFromInitializeLine(line) {
|
|
100
239
|
try {
|
|
101
240
|
const message = JSON.parse(line);
|
|
@@ -104,7 +243,7 @@ function clientSlugFromInitializeLine(line) {
|
|
|
104
243
|
const slug = name
|
|
105
244
|
.trim()
|
|
106
245
|
.toLowerCase()
|
|
107
|
-
.replace(/[^a-z0-
|
|
246
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
108
247
|
.replace(/^-+|-+$/g, "");
|
|
109
248
|
return slug || null;
|
|
110
249
|
} catch {
|
|
@@ -132,9 +271,14 @@ async function showDeviceCodeDialog(verificationUri, userCode) {
|
|
|
132
271
|
// mid-session refreshes of an already-visible, already-established session (see forwardLine
|
|
133
272
|
// below) — only for the initial per-process acquisition, so a long-lived connection doesn't
|
|
134
273
|
// spam a notification every time its token happens to roll over.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
274
|
+
// AUTH-54: now includes the identity label alongside the client slug — AUTH-51's original
|
|
275
|
+
// notification could only ever say "which app", not "reused as whom", which is exactly the
|
|
276
|
+
// distinction someone needs in order to notice a stale identity is still active.
|
|
277
|
+
async function notifySessionReused(clientSlug, identityLabel) {
|
|
278
|
+
const clientLabel = clientSlug ?? "an unidentified client";
|
|
279
|
+
const message = identityLabel
|
|
280
|
+
? `Reused an existing Roamer MCP session for ${clientLabel} (${identityLabel}).`
|
|
281
|
+
: `Reused an existing Roamer MCP session for ${clientLabel}.`;
|
|
138
282
|
try {
|
|
139
283
|
await execFileAsync("osascript", [
|
|
140
284
|
"-e",
|
|
@@ -146,8 +290,12 @@ async function notifySessionReused(clientSlug) {
|
|
|
146
290
|
log(message);
|
|
147
291
|
}
|
|
148
292
|
|
|
149
|
-
|
|
150
|
-
|
|
293
|
+
// AUTH-54: identityKey defaults to null (the pre-AUTH-54 file shape) rather than being
|
|
294
|
+
// required, so existing callers/tests that only ever knew about clientSlug keep working
|
|
295
|
+
// unchanged — production call sites in doGetValidTokens below always pass a real one once an
|
|
296
|
+
// identity is known.
|
|
297
|
+
async function readCachedTokens(clientSlug, identityKey = null) {
|
|
298
|
+
const path = cacheFilePath("roamer_tokens", clientSlug, identityKey);
|
|
151
299
|
if (!path) return null;
|
|
152
300
|
try {
|
|
153
301
|
const tokens = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -160,16 +308,19 @@ async function readCachedTokens(clientSlug) {
|
|
|
160
308
|
}
|
|
161
309
|
}
|
|
162
310
|
|
|
163
|
-
|
|
164
|
-
|
|
311
|
+
// AUTH-54: stamps `label` alongside the pre-existing `cacheVersion` so listCachedIdentities
|
|
312
|
+
// (AUTH-58) can read a human-readable identity straight off the file, without re-decoding the
|
|
313
|
+
// access token or keeping a separate index that could drift from what's actually on disk.
|
|
314
|
+
async function writeCachedTokens(clientSlug, tokens, identityKey = null) {
|
|
315
|
+
const path = cacheFilePath("roamer_tokens", clientSlug, identityKey);
|
|
165
316
|
if (!path) return; // AUTH-51: no usable client identity — never persisted.
|
|
166
317
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
167
|
-
const stamped = { ...tokens, cacheVersion: CACHE_VERSION };
|
|
318
|
+
const stamped = { ...tokens, cacheVersion: CACHE_VERSION, label: identityLabelFromTokens(tokens) };
|
|
168
319
|
await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
169
320
|
}
|
|
170
321
|
|
|
171
|
-
function clearCachedTokens(clientSlug) {
|
|
172
|
-
const path = cacheFilePath("roamer_tokens", clientSlug);
|
|
322
|
+
function clearCachedTokens(clientSlug, identityKey = null) {
|
|
323
|
+
const path = cacheFilePath("roamer_tokens", clientSlug, identityKey);
|
|
173
324
|
if (!path) return Promise.resolve();
|
|
174
325
|
return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
|
|
175
326
|
}
|
|
@@ -181,8 +332,10 @@ function clearCachedTokens(clientSlug) {
|
|
|
181
332
|
// discovery documents from Cloudflare Access Managed OAuth to RoamerMcp's own AS, and a client_id
|
|
182
333
|
// or token cached from before that change would otherwise be silently carried forward and
|
|
183
334
|
// presented to a completely different issuer with no invalidation at all.
|
|
184
|
-
|
|
185
|
-
|
|
335
|
+
// AUTH-54: identityKey defaults to null for the same backward-compatibility reason as
|
|
336
|
+
// readCachedTokens above.
|
|
337
|
+
async function readLoopbackTokens(clientSlug, issuerUrl, identityKey = null) {
|
|
338
|
+
const path = cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey);
|
|
186
339
|
if (!path) return null;
|
|
187
340
|
try {
|
|
188
341
|
const tokens = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -193,16 +346,16 @@ async function readLoopbackTokens(clientSlug, issuerUrl) {
|
|
|
193
346
|
}
|
|
194
347
|
}
|
|
195
348
|
|
|
196
|
-
async function writeLoopbackTokens(clientSlug, tokens, issuerUrl) {
|
|
197
|
-
const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
|
|
349
|
+
async function writeLoopbackTokens(clientSlug, tokens, issuerUrl, identityKey = null) {
|
|
350
|
+
const path = cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey);
|
|
198
351
|
if (!path) return; // AUTH-51: no usable client identity — never persisted.
|
|
199
352
|
await mkdir(CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
200
|
-
const stamped = { ...tokens, issuerUrl };
|
|
353
|
+
const stamped = { ...tokens, issuerUrl, label: identityLabelFromTokens(tokens) };
|
|
201
354
|
await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
202
355
|
}
|
|
203
356
|
|
|
204
|
-
function clearLoopbackTokens(clientSlug) {
|
|
205
|
-
const path = cacheFilePath("roamer_loopback_tokens", clientSlug);
|
|
357
|
+
function clearLoopbackTokens(clientSlug, identityKey = null) {
|
|
358
|
+
const path = cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey);
|
|
206
359
|
if (!path) return Promise.resolve();
|
|
207
360
|
return writeFile(path, "{}", { mode: 0o600 }).catch(() => {});
|
|
208
361
|
}
|
|
@@ -360,38 +513,65 @@ async function pollDeviceFlow(deviceCode, intervalSeconds) {
|
|
|
360
513
|
// more than one client's flow in flight within a single process anyway.
|
|
361
514
|
let inFlightTokens = null;
|
|
362
515
|
|
|
363
|
-
function getValidTokens(clientSlug, forceRefresh = false) {
|
|
516
|
+
function getValidTokens(clientSlug, forceRefresh = false, forceFreshLogin = false) {
|
|
364
517
|
if (inFlightTokens) return inFlightTokens;
|
|
365
|
-
inFlightTokens = doGetValidTokens(clientSlug, forceRefresh).finally(() => {
|
|
518
|
+
inFlightTokens = doGetValidTokens(clientSlug, forceRefresh, forceFreshLogin).finally(() => {
|
|
366
519
|
inFlightTokens = null;
|
|
367
520
|
});
|
|
368
521
|
return inFlightTokens;
|
|
369
522
|
}
|
|
370
523
|
|
|
371
|
-
// AUTH-51: returns { tokens, reusedSilently } — reusedSilently is true
|
|
372
|
-
// interactive step ran this call (a valid cache hit or a silent refresh-token
|
|
373
|
-
// which is exactly the case that used to be invisible and is now what triggers
|
|
374
|
-
// notifySessionReused in main().
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
524
|
+
// AUTH-51/54: returns { tokens, reusedSilently, identityKey, label } — reusedSilently is true
|
|
525
|
+
// only when no interactive step ran this call (a valid cache hit or a silent refresh-token
|
|
526
|
+
// refresh), which is exactly the case that used to be invisible and is now what triggers
|
|
527
|
+
// notifySessionReused in main(). identityKey/label are the person this run is/became — resolved
|
|
528
|
+
// from the active-identity pointer (AUTH-54) for a normal run, or freshly derived once a
|
|
529
|
+
// brand-new interactive sign-in completes.
|
|
530
|
+
//
|
|
531
|
+
// AUTH-55: forceFreshLogin (distinct from the pre-existing forceRefresh) is the `login`
|
|
532
|
+
// command's own entry point — unlike forceRefresh, which still operates against whichever
|
|
533
|
+
// identity is CURRENTLY active (the existing AUTH-51/reactive-401-invalidation behavior,
|
|
534
|
+
// unchanged), forceFreshLogin skips resolving the active identity at all, always runs a fresh
|
|
535
|
+
// interactive sign-in, and then makes WHOEVER signs in the new active identity — without
|
|
536
|
+
// deleting any other identity's own cache entry.
|
|
537
|
+
async function doGetValidTokens(clientSlug, forceRefresh, forceFreshLogin = false) {
|
|
538
|
+
const active = forceFreshLogin ? null : await readActiveIdentity(clientSlug);
|
|
539
|
+
const identityKey = active?.identityKey ?? null;
|
|
540
|
+
|
|
541
|
+
// BUG (found live, same session, immediately post-1.5.0-publish): identityKey being null
|
|
542
|
+
// here is ambiguous between "no active identity has ever been established" (must never read
|
|
543
|
+
// any file — force a fresh sign-in) and "explicitly asked for the identity-less cache" (only
|
|
544
|
+
// ever true from a 2-arg test call). cacheFilePath maps a null identityKey to the exact same
|
|
545
|
+
// filename AUTH-54 shipped alongside — the pre-AUTH-54 file every already-authenticated
|
|
546
|
+
// client already had on disk. Without this guard, a machine upgrading straight from a
|
|
547
|
+
// pre-AUTH-54 version silently keeps reusing that old file forever (a real "reused existing
|
|
548
|
+
// creds" notification a user hit immediately after this shipped) and never establishes an
|
|
549
|
+
// active-identity pointer at all — reproducing the exact silent-stale-identity bug AUTH-54
|
|
550
|
+
// exists to fix, and permanently hiding that identity from login/logout/status, which only
|
|
551
|
+
// ever look at the new (client, identity)-keyed files. noActiveIdentity forces the same
|
|
552
|
+
// fresh-sign-in path a genuinely first-ever run takes, which is what self-heals into a real
|
|
553
|
+
// active pointer + a new-format file — see the fresh-sign-in branch below.
|
|
554
|
+
const noActiveIdentity = !forceFreshLogin && !active;
|
|
555
|
+
|
|
556
|
+
let tokens = forceRefresh || forceFreshLogin || noActiveIdentity ? null : await readCachedTokens(clientSlug, identityKey);
|
|
557
|
+
|
|
558
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
|
|
559
|
+
return { tokens, reusedSilently: true, identityKey, label: active?.label };
|
|
380
560
|
}
|
|
381
561
|
|
|
382
|
-
if (!forceRefresh && tokens?.refresh_token) {
|
|
562
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token) {
|
|
383
563
|
try {
|
|
384
564
|
log("Refreshing cached token...");
|
|
385
565
|
const fresh = await refreshTokens(tokens.refresh_token);
|
|
386
566
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
387
|
-
await writeCachedTokens(clientSlug, tokens);
|
|
388
|
-
return { tokens, reusedSilently: true };
|
|
567
|
+
await writeCachedTokens(clientSlug, tokens, identityKey);
|
|
568
|
+
return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
|
|
389
569
|
} catch (err) {
|
|
390
570
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
391
571
|
}
|
|
392
572
|
}
|
|
393
573
|
|
|
394
|
-
if (forceRefresh) await clearCachedTokens(clientSlug);
|
|
574
|
+
if (forceRefresh && identityKey) await clearCachedTokens(clientSlug, identityKey);
|
|
395
575
|
|
|
396
576
|
// Resume an already-in-progress flow (from a process this host killed and respawned)
|
|
397
577
|
// instead of minting a new device_code the user would have to start over for.
|
|
@@ -430,10 +610,16 @@ async function doGetValidTokens(clientSlug, forceRefresh) {
|
|
|
430
610
|
try {
|
|
431
611
|
const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
|
|
432
612
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
433
|
-
|
|
613
|
+
const freshIdentityKey = identityKeyFromTokens(tokens);
|
|
614
|
+
const freshLabel = identityLabelFromTokens(tokens);
|
|
615
|
+
await writeCachedTokens(clientSlug, tokens, freshIdentityKey);
|
|
616
|
+
// AUTH-54/55: every fresh sign-in (not just an explicit `login`) re-establishes the active
|
|
617
|
+
// pointer — a routine cold start after a cache miss self-heals into a correctly-recorded
|
|
618
|
+
// active identity exactly like it always implicitly did before this item, just now explicit.
|
|
619
|
+
if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
|
|
434
620
|
await clearPendingFlow(clientSlug);
|
|
435
621
|
log("Sign-in complete.");
|
|
436
|
-
return { tokens, reusedSilently: false };
|
|
622
|
+
return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
|
|
437
623
|
} catch (err) {
|
|
438
624
|
// A hard failure (expired/denied, not just this process being killed) means the pending
|
|
439
625
|
// code is genuinely dead — clear it so the next attempt starts a real fresh one instead
|
|
@@ -509,44 +695,55 @@ async function discoverLoopbackServerInfo() {
|
|
|
509
695
|
// of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
|
|
510
696
|
let inFlightLoopbackTokens = null;
|
|
511
697
|
|
|
512
|
-
function getValidTokensLoopback(clientSlug, forceRefresh = false) {
|
|
698
|
+
function getValidTokensLoopback(clientSlug, forceRefresh = false, forceFreshLogin = false) {
|
|
513
699
|
if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
|
|
514
|
-
inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
|
|
700
|
+
inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin).finally(() => {
|
|
515
701
|
inFlightLoopbackTokens = null;
|
|
516
702
|
});
|
|
517
703
|
return inFlightLoopbackTokens;
|
|
518
704
|
}
|
|
519
705
|
|
|
520
|
-
// AUTH-51: returns { tokens, reusedSilently } — see
|
|
521
|
-
// contract for the
|
|
522
|
-
|
|
706
|
+
// AUTH-51/54/55: returns { tokens, reusedSilently, identityKey, label } — see
|
|
707
|
+
// doGetValidTokens's comment above, same contract and same forceFreshLogin semantics, for the
|
|
708
|
+
// loopback mechanism.
|
|
709
|
+
async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin = false) {
|
|
523
710
|
// AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
|
|
524
711
|
// cached-but-not-yet-expired token from a prior authorization server would otherwise be
|
|
525
712
|
// returned early below without ever learning the issuer changed underneath it.
|
|
526
713
|
const serverInfo = await discoverLoopbackServerInfo();
|
|
527
714
|
const issuerUrl = serverInfo.authorizationServerUrl.toString();
|
|
528
715
|
|
|
529
|
-
|
|
716
|
+
const active = forceFreshLogin ? null : await readActiveIdentity(clientSlug);
|
|
717
|
+
const identityKey = active?.identityKey ?? null;
|
|
718
|
+
// See doGetValidTokens's identical guard above for why this is required, not optional: a
|
|
719
|
+
// null identityKey here would otherwise silently resolve to the exact pre-AUTH-54 filename.
|
|
720
|
+
const noActiveIdentity = !forceFreshLogin && !active;
|
|
721
|
+
|
|
722
|
+
let tokens =
|
|
723
|
+
forceRefresh || forceFreshLogin || noActiveIdentity ? null : await readLoopbackTokens(clientSlug, issuerUrl, identityKey);
|
|
530
724
|
|
|
531
|
-
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
532
|
-
return { tokens, reusedSilently: true };
|
|
725
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
|
|
726
|
+
return { tokens, reusedSilently: true, identityKey, label: active?.label };
|
|
533
727
|
}
|
|
534
728
|
|
|
729
|
+
// AUTH-54: DCR client registration stays clientSlug-only, deliberately not identity-scoped —
|
|
730
|
+
// it identifies this app instance to the authorization server, not the person using it, so
|
|
731
|
+
// every identity signing in from the same client-slug reuses the same registered client_id.
|
|
535
732
|
let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
|
|
536
733
|
|
|
537
|
-
if (!forceRefresh && tokens?.refresh_token && clientInformation) {
|
|
734
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token && clientInformation) {
|
|
538
735
|
try {
|
|
539
736
|
log("Refreshing cached token...");
|
|
540
737
|
const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
|
|
541
738
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
542
|
-
await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
|
|
543
|
-
return { tokens, reusedSilently: true };
|
|
739
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl, identityKey);
|
|
740
|
+
return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
|
|
544
741
|
} catch (err) {
|
|
545
742
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
546
743
|
}
|
|
547
744
|
}
|
|
548
745
|
|
|
549
|
-
if (forceRefresh) await clearLoopbackTokens(clientSlug);
|
|
746
|
+
if (forceRefresh && identityKey) await clearLoopbackTokens(clientSlug, identityKey);
|
|
550
747
|
|
|
551
748
|
if (!clientInformation) {
|
|
552
749
|
log("Registering as a new OAuth client...");
|
|
@@ -616,9 +813,14 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
|
|
|
616
813
|
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
617
814
|
});
|
|
618
815
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
619
|
-
|
|
816
|
+
const freshIdentityKey = identityKeyFromTokens(tokens);
|
|
817
|
+
const freshLabel = identityLabelFromTokens(tokens);
|
|
818
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl, freshIdentityKey);
|
|
819
|
+
// AUTH-54/55: see doGetValidTokens's identical step for the device-code path — a routine
|
|
820
|
+
// cold start self-heals the active pointer the same way an explicit login does.
|
|
821
|
+
if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
|
|
620
822
|
log("Sign-in complete.");
|
|
621
|
-
return { tokens, reusedSilently: false };
|
|
823
|
+
return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
|
|
622
824
|
}
|
|
623
825
|
|
|
624
826
|
// AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
|
|
@@ -746,14 +948,24 @@ async function main() {
|
|
|
746
948
|
// redirect, including refreshes and reactive re-auth below.
|
|
747
949
|
const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
|
|
748
950
|
getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
749
|
-
|
|
951
|
+
// AUTH-54: resolves whichever identity is currently active before clearing — clearing the
|
|
952
|
+
// pre-AUTH-54, identity-less file path would silently no-op now that real caches live under
|
|
953
|
+
// an identity-scoped filename.
|
|
954
|
+
clearFreshTokens = async () => {
|
|
955
|
+
const active = await readActiveIdentity(clientSlug);
|
|
956
|
+
return isSafari
|
|
957
|
+
? clearCachedTokens(clientSlug, active?.identityKey)
|
|
958
|
+
: clearLoopbackTokens(clientSlug, active?.identityKey);
|
|
959
|
+
};
|
|
750
960
|
freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
|
|
751
961
|
|
|
752
962
|
let reusedSilently = false;
|
|
963
|
+
let identityLabel = null;
|
|
753
964
|
try {
|
|
754
965
|
const result = await getFreshTokens(clientSlug);
|
|
755
966
|
tokens = result.tokens;
|
|
756
967
|
reusedSilently = result.reusedSilently;
|
|
968
|
+
identityLabel = result.label;
|
|
757
969
|
} catch (err) {
|
|
758
970
|
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
759
971
|
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
@@ -763,9 +975,15 @@ async function main() {
|
|
|
763
975
|
process.exit(1);
|
|
764
976
|
}
|
|
765
977
|
|
|
978
|
+
// AUTH-56: unconditional, on every run (cache-hit or fresh sign-in alike) — unlike the
|
|
979
|
+
// reused-only notification below, this is the persistent record that a running session used
|
|
980
|
+
// this identity, findable later in the MCP host's own log panel rather than only at the
|
|
981
|
+
// instant a transient OS notification fired.
|
|
982
|
+
log(identityLabel ? `Authenticated as ${identityLabel} for client "${clientSlug}".` : `Authenticated for client "${clientSlug}" (no identity claim found on the token).`);
|
|
983
|
+
|
|
766
984
|
// AUTH-51: the moment that used to be completely silent — a process starting up and
|
|
767
985
|
// immediately using a credential it never interactively obtained this run.
|
|
768
|
-
if (reusedSilently) await notifySessionReused(clientSlug);
|
|
986
|
+
if (reusedSilently) await notifySessionReused(clientSlug, identityLabel);
|
|
769
987
|
|
|
770
988
|
transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
|
|
771
989
|
requestInit: {
|
|
@@ -798,6 +1016,125 @@ async function main() {
|
|
|
798
1016
|
}
|
|
799
1017
|
}
|
|
800
1018
|
|
|
1019
|
+
// AUTH-55/57/58: login/logout/status share this — none of them get a client slug from an MCP
|
|
1020
|
+
// `initialize` handshake the way normal bridge operation does (they run standalone), so all
|
|
1021
|
+
// three require it spelled out explicitly rather than guessing.
|
|
1022
|
+
function parseClientFlag(args) {
|
|
1023
|
+
const idx = args.indexOf("--client");
|
|
1024
|
+
if (idx === -1 || !args[idx + 1]) return null;
|
|
1025
|
+
return args[idx + 1];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async function requireClientFlag(args, commandName) {
|
|
1029
|
+
const clientSlug = parseClientFlag(args);
|
|
1030
|
+
if (clientSlug) return clientSlug;
|
|
1031
|
+
const known = await listKnownClientSlugs();
|
|
1032
|
+
const suggestion = known.length
|
|
1033
|
+
? `Known client slugs with a cached identity: ${known.join(", ")}.`
|
|
1034
|
+
: "No client has ever cached an identity on this machine yet.";
|
|
1035
|
+
log(`${commandName} requires --client <slug>. ${suggestion}`);
|
|
1036
|
+
process.exitCode = 1;
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function deleteCacheFile(path) {
|
|
1041
|
+
if (!path) return Promise.resolve();
|
|
1042
|
+
return unlink(path).catch(() => {});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// AUTH-55: the deliberate, one-shot counterpart to normal bridge startup's silent reuse —
|
|
1046
|
+
// always runs a fresh interactive sign-in (forceFreshLogin, see doGetValidTokens/
|
|
1047
|
+
// doGetValidTokensLoopback) via whichever mechanism (device-code vs. loopback) this machine
|
|
1048
|
+
// would actually use at real connect time, then makes whoever signs in the new active identity
|
|
1049
|
+
// for --client, without touching any other identity already cached for it.
|
|
1050
|
+
async function loginCommand(args) {
|
|
1051
|
+
const clientSlug = await requireClientFlag(args, "login");
|
|
1052
|
+
if (!clientSlug) return;
|
|
1053
|
+
|
|
1054
|
+
const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
|
|
1055
|
+
const getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
1056
|
+
try {
|
|
1057
|
+
const result = await getFreshTokens(clientSlug, false, /* forceFreshLogin */ true);
|
|
1058
|
+
log(`Signed in as ${result.label ?? "(no identity claim found on the token)"} — now the active identity for "${clientSlug}".`);
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
log(`Login failed: ${err.message}`);
|
|
1061
|
+
process.exitCode = 1;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// AUTH-57: default scope is the active identity only — clearing it (and the active-identity
|
|
1066
|
+
// pointer itself) is enough to make the client's next normal launch fall back to a fresh
|
|
1067
|
+
// interactive sign-in, per AUTH-57's own Given/When/Then; --all additionally clears every
|
|
1068
|
+
// other identity ever cached for this client slug, for a full offboard/cleanup.
|
|
1069
|
+
async function logoutCommand(args) {
|
|
1070
|
+
const clientSlug = await requireClientFlag(args, "logout");
|
|
1071
|
+
if (!clientSlug) return;
|
|
1072
|
+
|
|
1073
|
+
if (args.includes("--all")) {
|
|
1074
|
+
const identities = await listCachedIdentities(clientSlug);
|
|
1075
|
+
if (identities.length === 0) {
|
|
1076
|
+
log(`Nothing cached for "${clientSlug}".`);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
for (const { identityKey, label } of identities) {
|
|
1080
|
+
await Promise.all([
|
|
1081
|
+
deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, identityKey)),
|
|
1082
|
+
deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey)),
|
|
1083
|
+
]);
|
|
1084
|
+
log(`Cleared cached identity ${label} for "${clientSlug}".`);
|
|
1085
|
+
}
|
|
1086
|
+
await clearActiveIdentity(clientSlug);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const active = await readActiveIdentity(clientSlug);
|
|
1091
|
+
if (!active) {
|
|
1092
|
+
log(`"${clientSlug}" has no active identity to log out of.`);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
await Promise.all([
|
|
1096
|
+
deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, active.identityKey)),
|
|
1097
|
+
deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, active.identityKey)),
|
|
1098
|
+
]);
|
|
1099
|
+
await clearActiveIdentity(clientSlug);
|
|
1100
|
+
log(`Logged out ${active.label} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// AUTH-58: --client lists that one slug's cached identities; omitting it surveys every client
|
|
1104
|
+
// slug that has cached anything at all, one summary line each.
|
|
1105
|
+
async function statusCommand(args) {
|
|
1106
|
+
const clientSlug = parseClientFlag(args);
|
|
1107
|
+
if (clientSlug) {
|
|
1108
|
+
const identities = await listCachedIdentities(clientSlug);
|
|
1109
|
+
if (identities.length === 0) {
|
|
1110
|
+
log(`No identities cached for "${clientSlug}".`);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
for (const { label, active } of identities) {
|
|
1114
|
+
log(`${active ? "*" : " "} ${label}${active ? " (active)" : ""} — client "${clientSlug}"`);
|
|
1115
|
+
}
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const slugs = await listKnownClientSlugs();
|
|
1120
|
+
if (slugs.length === 0) {
|
|
1121
|
+
log("No identities cached for any client on this machine.");
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
for (const slug of slugs) {
|
|
1125
|
+
const identities = await listCachedIdentities(slug);
|
|
1126
|
+
const activeLabel = identities.find((i) => i.active)?.label ?? "none";
|
|
1127
|
+
log(`${slug}: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} cached, active = ${activeLabel}`);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function runCli(promise) {
|
|
1132
|
+
promise.catch((err) => {
|
|
1133
|
+
log(`Fatal error: ${err.stack ?? err.message}`);
|
|
1134
|
+
process.exit(1);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
801
1138
|
// Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
|
|
802
1139
|
// must not trigger a live device-auth flow and stdio takeover as a side effect.
|
|
803
1140
|
// AUTH-29: npx always launches this via the node_modules/.bin symlink, so argv[1] must be
|
|
@@ -805,10 +1142,15 @@ async function main() {
|
|
|
805
1142
|
const isMainModule =
|
|
806
1143
|
process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
807
1144
|
if (isMainModule) {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1145
|
+
// AUTH-55/57/58: a subcommand routes to login/logout/status(/whoami); anything else —
|
|
1146
|
+
// including today's only invocation shape, zero args — falls through to the normal MCP
|
|
1147
|
+
// stdio bridge unchanged. This is the one and only new branch point; every existing caller
|
|
1148
|
+
// (roamer-bridge.sh, every plugin/client config) passes no args and is unaffected.
|
|
1149
|
+
const [subcommand, ...rest] = process.argv.slice(2);
|
|
1150
|
+
if (subcommand === "login") runCli(loginCommand(rest));
|
|
1151
|
+
else if (subcommand === "logout") runCli(logoutCommand(rest));
|
|
1152
|
+
else if (subcommand === "status" || subcommand === "whoami") runCli(statusCommand(rest));
|
|
1153
|
+
else runCli(main());
|
|
812
1154
|
}
|
|
813
1155
|
|
|
814
1156
|
export {
|
|
@@ -825,4 +1167,17 @@ export {
|
|
|
825
1167
|
writeLoopbackTokens,
|
|
826
1168
|
readLoopbackClientInfo,
|
|
827
1169
|
writeLoopbackClientInfo,
|
|
1170
|
+
// AUTH-54/55/57/58
|
|
1171
|
+
cacheFilePath,
|
|
1172
|
+
identityKeyFromTokens,
|
|
1173
|
+
identityLabelFromTokens,
|
|
1174
|
+
readActiveIdentity,
|
|
1175
|
+
writeActiveIdentity,
|
|
1176
|
+
clearActiveIdentity,
|
|
1177
|
+
listCachedIdentities,
|
|
1178
|
+
listKnownClientSlugs,
|
|
1179
|
+
parseClientFlag,
|
|
1180
|
+
loginCommand,
|
|
1181
|
+
logoutCommand,
|
|
1182
|
+
statusCommand,
|
|
828
1183
|
};
|