@mathismeadows/roamer-device-auth 1.4.2 → 1.5.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 -2
- package/roamer-device-auth.mjs +394 -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.0",
|
|
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,50 @@ 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
|
+
let tokens = forceRefresh || forceFreshLogin ? null : await readCachedTokens(clientSlug, identityKey);
|
|
542
|
+
|
|
543
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
|
|
544
|
+
return { tokens, reusedSilently: true, identityKey, label: active?.label };
|
|
380
545
|
}
|
|
381
546
|
|
|
382
|
-
if (!forceRefresh && tokens?.refresh_token) {
|
|
547
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token) {
|
|
383
548
|
try {
|
|
384
549
|
log("Refreshing cached token...");
|
|
385
550
|
const fresh = await refreshTokens(tokens.refresh_token);
|
|
386
551
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
387
|
-
await writeCachedTokens(clientSlug, tokens);
|
|
388
|
-
return { tokens, reusedSilently: true };
|
|
552
|
+
await writeCachedTokens(clientSlug, tokens, identityKey);
|
|
553
|
+
return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
|
|
389
554
|
} catch (err) {
|
|
390
555
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
391
556
|
}
|
|
392
557
|
}
|
|
393
558
|
|
|
394
|
-
if (forceRefresh) await clearCachedTokens(clientSlug);
|
|
559
|
+
if (forceRefresh && identityKey) await clearCachedTokens(clientSlug, identityKey);
|
|
395
560
|
|
|
396
561
|
// Resume an already-in-progress flow (from a process this host killed and respawned)
|
|
397
562
|
// instead of minting a new device_code the user would have to start over for.
|
|
@@ -430,10 +595,16 @@ async function doGetValidTokens(clientSlug, forceRefresh) {
|
|
|
430
595
|
try {
|
|
431
596
|
const fresh = await pollDeviceFlow(device.device_code, device.interval ?? 5);
|
|
432
597
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
433
|
-
|
|
598
|
+
const freshIdentityKey = identityKeyFromTokens(tokens);
|
|
599
|
+
const freshLabel = identityLabelFromTokens(tokens);
|
|
600
|
+
await writeCachedTokens(clientSlug, tokens, freshIdentityKey);
|
|
601
|
+
// AUTH-54/55: every fresh sign-in (not just an explicit `login`) re-establishes the active
|
|
602
|
+
// pointer — a routine cold start after a cache miss self-heals into a correctly-recorded
|
|
603
|
+
// active identity exactly like it always implicitly did before this item, just now explicit.
|
|
604
|
+
if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
|
|
434
605
|
await clearPendingFlow(clientSlug);
|
|
435
606
|
log("Sign-in complete.");
|
|
436
|
-
return { tokens, reusedSilently: false };
|
|
607
|
+
return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
|
|
437
608
|
} catch (err) {
|
|
438
609
|
// A hard failure (expired/denied, not just this process being killed) means the pending
|
|
439
610
|
// code is genuinely dead — clear it so the next attempt starts a real fresh one instead
|
|
@@ -509,44 +680,51 @@ async function discoverLoopbackServerInfo() {
|
|
|
509
680
|
// of the two mechanisms is ever active in a given process (see detectDefaultBrowser).
|
|
510
681
|
let inFlightLoopbackTokens = null;
|
|
511
682
|
|
|
512
|
-
function getValidTokensLoopback(clientSlug, forceRefresh = false) {
|
|
683
|
+
function getValidTokensLoopback(clientSlug, forceRefresh = false, forceFreshLogin = false) {
|
|
513
684
|
if (inFlightLoopbackTokens) return inFlightLoopbackTokens;
|
|
514
|
-
inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh).finally(() => {
|
|
685
|
+
inFlightLoopbackTokens = doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin).finally(() => {
|
|
515
686
|
inFlightLoopbackTokens = null;
|
|
516
687
|
});
|
|
517
688
|
return inFlightLoopbackTokens;
|
|
518
689
|
}
|
|
519
690
|
|
|
520
|
-
// AUTH-51: returns { tokens, reusedSilently } — see
|
|
521
|
-
// contract for the
|
|
522
|
-
|
|
691
|
+
// AUTH-51/54/55: returns { tokens, reusedSilently, identityKey, label } — see
|
|
692
|
+
// doGetValidTokens's comment above, same contract and same forceFreshLogin semantics, for the
|
|
693
|
+
// loopback mechanism.
|
|
694
|
+
async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogin = false) {
|
|
523
695
|
// AUTH-38: discovery has to run before any cached token/client is trusted, not after — a
|
|
524
696
|
// cached-but-not-yet-expired token from a prior authorization server would otherwise be
|
|
525
697
|
// returned early below without ever learning the issuer changed underneath it.
|
|
526
698
|
const serverInfo = await discoverLoopbackServerInfo();
|
|
527
699
|
const issuerUrl = serverInfo.authorizationServerUrl.toString();
|
|
528
700
|
|
|
529
|
-
|
|
701
|
+
const active = forceFreshLogin ? null : await readActiveIdentity(clientSlug);
|
|
702
|
+
const identityKey = active?.identityKey ?? null;
|
|
703
|
+
|
|
704
|
+
let tokens = forceRefresh || forceFreshLogin ? null : await readLoopbackTokens(clientSlug, issuerUrl, identityKey);
|
|
530
705
|
|
|
531
|
-
if (!forceRefresh && tokens?.access_token && !expiresSoon(tokens)) {
|
|
532
|
-
return { tokens, reusedSilently: true };
|
|
706
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.access_token && !expiresSoon(tokens)) {
|
|
707
|
+
return { tokens, reusedSilently: true, identityKey, label: active?.label };
|
|
533
708
|
}
|
|
534
709
|
|
|
710
|
+
// AUTH-54: DCR client registration stays clientSlug-only, deliberately not identity-scoped —
|
|
711
|
+
// it identifies this app instance to the authorization server, not the person using it, so
|
|
712
|
+
// every identity signing in from the same client-slug reuses the same registered client_id.
|
|
535
713
|
let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
|
|
536
714
|
|
|
537
|
-
if (!forceRefresh && tokens?.refresh_token && clientInformation) {
|
|
715
|
+
if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token && clientInformation) {
|
|
538
716
|
try {
|
|
539
717
|
log("Refreshing cached token...");
|
|
540
718
|
const fresh = await refreshLoopbackTokens(tokens.refresh_token, serverInfo, clientInformation);
|
|
541
719
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
542
|
-
await writeLoopbackTokens(clientSlug, tokens, issuerUrl);
|
|
543
|
-
return { tokens, reusedSilently: true };
|
|
720
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl, identityKey);
|
|
721
|
+
return { tokens, reusedSilently: true, identityKey, label: identityLabelFromTokens(tokens) };
|
|
544
722
|
} catch (err) {
|
|
545
723
|
log(`Refresh failed (${err.message}), falling back to a fresh sign-in.`);
|
|
546
724
|
}
|
|
547
725
|
}
|
|
548
726
|
|
|
549
|
-
if (forceRefresh) await clearLoopbackTokens(clientSlug);
|
|
727
|
+
if (forceRefresh && identityKey) await clearLoopbackTokens(clientSlug, identityKey);
|
|
550
728
|
|
|
551
729
|
if (!clientInformation) {
|
|
552
730
|
log("Registering as a new OAuth client...");
|
|
@@ -616,9 +794,14 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh) {
|
|
|
616
794
|
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
617
795
|
});
|
|
618
796
|
tokens = { ...fresh, obtained_at: Date.now() };
|
|
619
|
-
|
|
797
|
+
const freshIdentityKey = identityKeyFromTokens(tokens);
|
|
798
|
+
const freshLabel = identityLabelFromTokens(tokens);
|
|
799
|
+
await writeLoopbackTokens(clientSlug, tokens, issuerUrl, freshIdentityKey);
|
|
800
|
+
// AUTH-54/55: see doGetValidTokens's identical step for the device-code path — a routine
|
|
801
|
+
// cold start self-heals the active pointer the same way an explicit login does.
|
|
802
|
+
if (freshIdentityKey) await writeActiveIdentity(clientSlug, { identityKey: freshIdentityKey, label: freshLabel });
|
|
620
803
|
log("Sign-in complete.");
|
|
621
|
-
return { tokens, reusedSilently: false };
|
|
804
|
+
return { tokens, reusedSilently: false, identityKey: freshIdentityKey, label: freshLabel };
|
|
622
805
|
}
|
|
623
806
|
|
|
624
807
|
// AUTH-28: extracted so it can run both for live traffic (post-sign-in) and for messages
|
|
@@ -746,14 +929,24 @@ async function main() {
|
|
|
746
929
|
// redirect, including refreshes and reactive re-auth below.
|
|
747
930
|
const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
|
|
748
931
|
getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
749
|
-
|
|
932
|
+
// AUTH-54: resolves whichever identity is currently active before clearing — clearing the
|
|
933
|
+
// pre-AUTH-54, identity-less file path would silently no-op now that real caches live under
|
|
934
|
+
// an identity-scoped filename.
|
|
935
|
+
clearFreshTokens = async () => {
|
|
936
|
+
const active = await readActiveIdentity(clientSlug);
|
|
937
|
+
return isSafari
|
|
938
|
+
? clearCachedTokens(clientSlug, active?.identityKey)
|
|
939
|
+
: clearLoopbackTokens(clientSlug, active?.identityKey);
|
|
940
|
+
};
|
|
750
941
|
freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
|
|
751
942
|
|
|
752
943
|
let reusedSilently = false;
|
|
944
|
+
let identityLabel = null;
|
|
753
945
|
try {
|
|
754
946
|
const result = await getFreshTokens(clientSlug);
|
|
755
947
|
tokens = result.tokens;
|
|
756
948
|
reusedSilently = result.reusedSilently;
|
|
949
|
+
identityLabel = result.label;
|
|
757
950
|
} catch (err) {
|
|
758
951
|
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
759
952
|
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
@@ -763,9 +956,15 @@ async function main() {
|
|
|
763
956
|
process.exit(1);
|
|
764
957
|
}
|
|
765
958
|
|
|
959
|
+
// AUTH-56: unconditional, on every run (cache-hit or fresh sign-in alike) — unlike the
|
|
960
|
+
// reused-only notification below, this is the persistent record that a running session used
|
|
961
|
+
// this identity, findable later in the MCP host's own log panel rather than only at the
|
|
962
|
+
// instant a transient OS notification fired.
|
|
963
|
+
log(identityLabel ? `Authenticated as ${identityLabel} for client "${clientSlug}".` : `Authenticated for client "${clientSlug}" (no identity claim found on the token).`);
|
|
964
|
+
|
|
766
965
|
// AUTH-51: the moment that used to be completely silent — a process starting up and
|
|
767
966
|
// immediately using a credential it never interactively obtained this run.
|
|
768
|
-
if (reusedSilently) await notifySessionReused(clientSlug);
|
|
967
|
+
if (reusedSilently) await notifySessionReused(clientSlug, identityLabel);
|
|
769
968
|
|
|
770
969
|
transport = new StreamableHTTPClientTransport(new URL(ROAMER_MCP_URL), {
|
|
771
970
|
requestInit: {
|
|
@@ -798,6 +997,125 @@ async function main() {
|
|
|
798
997
|
}
|
|
799
998
|
}
|
|
800
999
|
|
|
1000
|
+
// AUTH-55/57/58: login/logout/status share this — none of them get a client slug from an MCP
|
|
1001
|
+
// `initialize` handshake the way normal bridge operation does (they run standalone), so all
|
|
1002
|
+
// three require it spelled out explicitly rather than guessing.
|
|
1003
|
+
function parseClientFlag(args) {
|
|
1004
|
+
const idx = args.indexOf("--client");
|
|
1005
|
+
if (idx === -1 || !args[idx + 1]) return null;
|
|
1006
|
+
return args[idx + 1];
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function requireClientFlag(args, commandName) {
|
|
1010
|
+
const clientSlug = parseClientFlag(args);
|
|
1011
|
+
if (clientSlug) return clientSlug;
|
|
1012
|
+
const known = await listKnownClientSlugs();
|
|
1013
|
+
const suggestion = known.length
|
|
1014
|
+
? `Known client slugs with a cached identity: ${known.join(", ")}.`
|
|
1015
|
+
: "No client has ever cached an identity on this machine yet.";
|
|
1016
|
+
log(`${commandName} requires --client <slug>. ${suggestion}`);
|
|
1017
|
+
process.exitCode = 1;
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function deleteCacheFile(path) {
|
|
1022
|
+
if (!path) return Promise.resolve();
|
|
1023
|
+
return unlink(path).catch(() => {});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// AUTH-55: the deliberate, one-shot counterpart to normal bridge startup's silent reuse —
|
|
1027
|
+
// always runs a fresh interactive sign-in (forceFreshLogin, see doGetValidTokens/
|
|
1028
|
+
// doGetValidTokensLoopback) via whichever mechanism (device-code vs. loopback) this machine
|
|
1029
|
+
// would actually use at real connect time, then makes whoever signs in the new active identity
|
|
1030
|
+
// for --client, without touching any other identity already cached for it.
|
|
1031
|
+
async function loginCommand(args) {
|
|
1032
|
+
const clientSlug = await requireClientFlag(args, "login");
|
|
1033
|
+
if (!clientSlug) return;
|
|
1034
|
+
|
|
1035
|
+
const isSafari = (await detectDefaultBrowser()) === "com.apple.safari";
|
|
1036
|
+
const getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
1037
|
+
try {
|
|
1038
|
+
const result = await getFreshTokens(clientSlug, false, /* forceFreshLogin */ true);
|
|
1039
|
+
log(`Signed in as ${result.label ?? "(no identity claim found on the token)"} — now the active identity for "${clientSlug}".`);
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
log(`Login failed: ${err.message}`);
|
|
1042
|
+
process.exitCode = 1;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// AUTH-57: default scope is the active identity only — clearing it (and the active-identity
|
|
1047
|
+
// pointer itself) is enough to make the client's next normal launch fall back to a fresh
|
|
1048
|
+
// interactive sign-in, per AUTH-57's own Given/When/Then; --all additionally clears every
|
|
1049
|
+
// other identity ever cached for this client slug, for a full offboard/cleanup.
|
|
1050
|
+
async function logoutCommand(args) {
|
|
1051
|
+
const clientSlug = await requireClientFlag(args, "logout");
|
|
1052
|
+
if (!clientSlug) return;
|
|
1053
|
+
|
|
1054
|
+
if (args.includes("--all")) {
|
|
1055
|
+
const identities = await listCachedIdentities(clientSlug);
|
|
1056
|
+
if (identities.length === 0) {
|
|
1057
|
+
log(`Nothing cached for "${clientSlug}".`);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
for (const { identityKey, label } of identities) {
|
|
1061
|
+
await Promise.all([
|
|
1062
|
+
deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, identityKey)),
|
|
1063
|
+
deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, identityKey)),
|
|
1064
|
+
]);
|
|
1065
|
+
log(`Cleared cached identity ${label} for "${clientSlug}".`);
|
|
1066
|
+
}
|
|
1067
|
+
await clearActiveIdentity(clientSlug);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const active = await readActiveIdentity(clientSlug);
|
|
1072
|
+
if (!active) {
|
|
1073
|
+
log(`"${clientSlug}" has no active identity to log out of.`);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
await Promise.all([
|
|
1077
|
+
deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, active.identityKey)),
|
|
1078
|
+
deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, active.identityKey)),
|
|
1079
|
+
]);
|
|
1080
|
+
await clearActiveIdentity(clientSlug);
|
|
1081
|
+
log(`Logged out ${active.label} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// AUTH-58: --client lists that one slug's cached identities; omitting it surveys every client
|
|
1085
|
+
// slug that has cached anything at all, one summary line each.
|
|
1086
|
+
async function statusCommand(args) {
|
|
1087
|
+
const clientSlug = parseClientFlag(args);
|
|
1088
|
+
if (clientSlug) {
|
|
1089
|
+
const identities = await listCachedIdentities(clientSlug);
|
|
1090
|
+
if (identities.length === 0) {
|
|
1091
|
+
log(`No identities cached for "${clientSlug}".`);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
for (const { label, active } of identities) {
|
|
1095
|
+
log(`${active ? "*" : " "} ${label}${active ? " (active)" : ""} — client "${clientSlug}"`);
|
|
1096
|
+
}
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const slugs = await listKnownClientSlugs();
|
|
1101
|
+
if (slugs.length === 0) {
|
|
1102
|
+
log("No identities cached for any client on this machine.");
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
for (const slug of slugs) {
|
|
1106
|
+
const identities = await listCachedIdentities(slug);
|
|
1107
|
+
const activeLabel = identities.find((i) => i.active)?.label ?? "none";
|
|
1108
|
+
log(`${slug}: ${identities.length} identit${identities.length === 1 ? "y" : "ies"} cached, active = ${activeLabel}`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function runCli(promise) {
|
|
1113
|
+
promise.catch((err) => {
|
|
1114
|
+
log(`Fatal error: ${err.stack ?? err.message}`);
|
|
1115
|
+
process.exit(1);
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
801
1119
|
// Only auto-run when invoked directly (npx/CLI) — importing this module from a test file
|
|
802
1120
|
// must not trigger a live device-auth flow and stdio takeover as a side effect.
|
|
803
1121
|
// AUTH-29: npx always launches this via the node_modules/.bin symlink, so argv[1] must be
|
|
@@ -805,10 +1123,15 @@ async function main() {
|
|
|
805
1123
|
const isMainModule =
|
|
806
1124
|
process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
807
1125
|
if (isMainModule) {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1126
|
+
// AUTH-55/57/58: a subcommand routes to login/logout/status(/whoami); anything else —
|
|
1127
|
+
// including today's only invocation shape, zero args — falls through to the normal MCP
|
|
1128
|
+
// stdio bridge unchanged. This is the one and only new branch point; every existing caller
|
|
1129
|
+
// (roamer-bridge.sh, every plugin/client config) passes no args and is unaffected.
|
|
1130
|
+
const [subcommand, ...rest] = process.argv.slice(2);
|
|
1131
|
+
if (subcommand === "login") runCli(loginCommand(rest));
|
|
1132
|
+
else if (subcommand === "logout") runCli(logoutCommand(rest));
|
|
1133
|
+
else if (subcommand === "status" || subcommand === "whoami") runCli(statusCommand(rest));
|
|
1134
|
+
else runCli(main());
|
|
812
1135
|
}
|
|
813
1136
|
|
|
814
1137
|
export {
|
|
@@ -825,4 +1148,17 @@ export {
|
|
|
825
1148
|
writeLoopbackTokens,
|
|
826
1149
|
readLoopbackClientInfo,
|
|
827
1150
|
writeLoopbackClientInfo,
|
|
1151
|
+
// AUTH-54/55/57/58
|
|
1152
|
+
cacheFilePath,
|
|
1153
|
+
identityKeyFromTokens,
|
|
1154
|
+
identityLabelFromTokens,
|
|
1155
|
+
readActiveIdentity,
|
|
1156
|
+
writeActiveIdentity,
|
|
1157
|
+
clearActiveIdentity,
|
|
1158
|
+
listCachedIdentities,
|
|
1159
|
+
listKnownClientSlugs,
|
|
1160
|
+
parseClientFlag,
|
|
1161
|
+
loginCommand,
|
|
1162
|
+
logoutCommand,
|
|
1163
|
+
statusCommand,
|
|
828
1164
|
};
|