@mathismeadows/roamer-device-auth 1.5.1 → 1.5.3
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 +73 -13
package/package.json
CHANGED
package/roamer-device-auth.mjs
CHANGED
|
@@ -111,6 +111,29 @@ function identityLabelFromTokens(tokens) {
|
|
|
111
111
|
return payload?.email ?? payload?.preferred_username ?? payload?.oid ?? null;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// AUTH-59: RoamerMcp-issued access tokens only ever carry oid/tid (see
|
|
115
|
+
// OAuthAuthorizationServerService.IssueAccessToken) — identityLabelFromTokens' email/
|
|
116
|
+
// preferred_username branches above are therefore dead in production, and every operator-
|
|
117
|
+
// facing label resolves to the raw oid without this. Resolves a human-readable label live
|
|
118
|
+
// from RoamerMcp's own Portal API instead, without ever persisting the result — only the
|
|
119
|
+
// oid-based fallback above is ever written to disk. Never throws: offline, a timed-out
|
|
120
|
+
// request, an expired/revoked token, or a non-200 response all just mean "no live label this
|
|
121
|
+
// time", same degrade-gracefully convention this file uses everywhere else.
|
|
122
|
+
async function fetchLiveLabel(accessToken) {
|
|
123
|
+
if (!accessToken) return null;
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`${ROAMER_MCP_ORIGIN}/api/portal/tenant-state`, {
|
|
126
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
127
|
+
signal: AbortSignal.timeout(3000),
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) return null;
|
|
130
|
+
const data = await response.json();
|
|
131
|
+
return data?.displayName || data?.email || null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
114
137
|
function decodeTokenPayload(tokens) {
|
|
115
138
|
try {
|
|
116
139
|
return JSON.parse(Buffer.from(tokens.access_token.split(".")[1], "base64url").toString("utf8"));
|
|
@@ -148,6 +171,20 @@ function clearActiveIdentity(clientSlug) {
|
|
|
148
171
|
return unlink(path).catch(() => {});
|
|
149
172
|
}
|
|
150
173
|
|
|
174
|
+
// AUTH-59: reads whichever of the two token-cache files exists for a given identity, so
|
|
175
|
+
// logoutCommand's default (active-only) path can resolve a live display label — from the
|
|
176
|
+
// access_token that file holds — before it deletes that same file.
|
|
177
|
+
async function readAnyTokensForIdentity(clientSlug, identityKey) {
|
|
178
|
+
for (const baseName of ["roamer_tokens", "roamer_loopback_tokens"]) {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(await readFile(cacheFilePath(baseName, clientSlug, identityKey), "utf8"));
|
|
181
|
+
} catch {
|
|
182
|
+
// try the other mechanism
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
151
188
|
// AUTH-58: lists every identity currently cached for a client slug, across both auth
|
|
152
189
|
// mechanisms (a machine's default browser — and therefore which mechanism is in play — can
|
|
153
190
|
// change over time; a person could have identities cached under either). Reads each file's own
|
|
@@ -156,7 +193,7 @@ function clearActiveIdentity(clientSlug) {
|
|
|
156
193
|
async function listCachedIdentities(clientSlug) {
|
|
157
194
|
const active = await readActiveIdentity(clientSlug);
|
|
158
195
|
const prefixes = [`roamer_tokens__${clientSlug}__`, `roamer_loopback_tokens__${clientSlug}__`];
|
|
159
|
-
const seen = new Map(); // identityKey -> label, deduped across the two mechanisms
|
|
196
|
+
const seen = new Map(); // identityKey -> { label, accessToken }, deduped across the two mechanisms
|
|
160
197
|
let entries;
|
|
161
198
|
try {
|
|
162
199
|
entries = await readdir(CACHE_DIR);
|
|
@@ -170,16 +207,20 @@ async function listCachedIdentities(clientSlug) {
|
|
|
170
207
|
if (!identityKey || seen.has(identityKey)) continue;
|
|
171
208
|
try {
|
|
172
209
|
const contents = JSON.parse(await readFile(join(CACHE_DIR, entry), "utf8"));
|
|
173
|
-
seen.set(identityKey, contents?.label ?? identityKey);
|
|
210
|
+
seen.set(identityKey, { label: contents?.label ?? identityKey, accessToken: contents?.access_token ?? null });
|
|
174
211
|
} catch {
|
|
175
|
-
seen.set(identityKey, identityKey);
|
|
212
|
+
seen.set(identityKey, { label: identityKey, accessToken: null });
|
|
176
213
|
}
|
|
177
214
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
215
|
+
// AUTH-59: resolved in parallel, live, on every call — never persisted back to `seen`'s
|
|
216
|
+
// source files.
|
|
217
|
+
return Promise.all(
|
|
218
|
+
[...seen.entries()].map(async ([identityKey, { label, accessToken }]) => ({
|
|
219
|
+
identityKey,
|
|
220
|
+
label: (await fetchLiveLabel(accessToken)) ?? label,
|
|
221
|
+
active: identityKey === active?.identityKey,
|
|
222
|
+
})),
|
|
223
|
+
);
|
|
183
224
|
}
|
|
184
225
|
|
|
185
226
|
// AUTH-55/57/58: login/logout/status run standalone, with no MCP `initialize` handshake to
|
|
@@ -197,8 +238,20 @@ async function listKnownClientSlugs() {
|
|
|
197
238
|
for (const entry of entries) {
|
|
198
239
|
// Safe to split on "__" without ambiguity: neither a client slug (clientSlugFromInitializeLine)
|
|
199
240
|
// nor an identity key (identityKeyFromTokens) can contain an underscore post-sanitization.
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
241
|
+
const withIdentity = entry.match(/^roamer_(?:tokens|loopback_tokens)__([^_]+)__/);
|
|
242
|
+
if (withIdentity) {
|
|
243
|
+
slugs.add(withIdentity[1]);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// BUG (found live 2026-09-08, caused a real cross-session logout): a slug that has never
|
|
247
|
+
// reconnected since AUTH-54 shipped only has the pre-AUTH-54 legacy file — no identity
|
|
248
|
+
// segment at all — and was invisible here. login/logout's own "if status lists exactly one
|
|
249
|
+
// slug, there's no ambiguity" reasoning then wrongly treated a real, actively-used client as
|
|
250
|
+
// nonexistent, silently acting on a *different* session's slug instead because it was the
|
|
251
|
+
// only one that happened to already be migrated. A legacy-only slug must still count as a
|
|
252
|
+
// real, known candidate even though it has no identity cached under the new scheme yet.
|
|
253
|
+
const legacy = entry.match(/^roamer_(?:tokens|loopback_tokens)__([^_]+)\.json$/);
|
|
254
|
+
if (legacy) slugs.add(legacy[1]);
|
|
202
255
|
}
|
|
203
256
|
return [...slugs].sort();
|
|
204
257
|
}
|
|
@@ -965,7 +1018,10 @@ async function main() {
|
|
|
965
1018
|
const result = await getFreshTokens(clientSlug);
|
|
966
1019
|
tokens = result.tokens;
|
|
967
1020
|
reusedSilently = result.reusedSilently;
|
|
968
|
-
|
|
1021
|
+
// AUTH-59: live lookup on every run, bounded by fetchLiveLabel's own short timeout — this
|
|
1022
|
+
// adds a network round trip to every ordinary client startup in exchange for a readable
|
|
1023
|
+
// log line; falls back to the existing oid-based label on any failure.
|
|
1024
|
+
identityLabel = (await fetchLiveLabel(result.tokens?.access_token)) ?? result.label;
|
|
969
1025
|
} catch (err) {
|
|
970
1026
|
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
971
1027
|
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
@@ -1055,7 +1111,8 @@ async function loginCommand(args) {
|
|
|
1055
1111
|
const getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
1056
1112
|
try {
|
|
1057
1113
|
const result = await getFreshTokens(clientSlug, false, /* forceFreshLogin */ true);
|
|
1058
|
-
|
|
1114
|
+
const displayLabel = (await fetchLiveLabel(result.tokens?.access_token)) ?? result.label;
|
|
1115
|
+
log(`Signed in as ${displayLabel ?? "(no identity claim found on the token)"} — now the active identity for "${clientSlug}".`);
|
|
1059
1116
|
} catch (err) {
|
|
1060
1117
|
log(`Login failed: ${err.message}`);
|
|
1061
1118
|
process.exitCode = 1;
|
|
@@ -1092,12 +1149,15 @@ async function logoutCommand(args) {
|
|
|
1092
1149
|
log(`"${clientSlug}" has no active identity to log out of.`);
|
|
1093
1150
|
return;
|
|
1094
1151
|
}
|
|
1152
|
+
// AUTH-59: resolved before deletion, from the very cache file about to be removed.
|
|
1153
|
+
const cachedTokens = await readAnyTokensForIdentity(clientSlug, active.identityKey);
|
|
1154
|
+
const displayLabel = (await fetchLiveLabel(cachedTokens?.access_token)) ?? active.label;
|
|
1095
1155
|
await Promise.all([
|
|
1096
1156
|
deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, active.identityKey)),
|
|
1097
1157
|
deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, active.identityKey)),
|
|
1098
1158
|
]);
|
|
1099
1159
|
await clearActiveIdentity(clientSlug);
|
|
1100
|
-
log(`Logged out ${
|
|
1160
|
+
log(`Logged out ${displayLabel} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
|
|
1101
1161
|
}
|
|
1102
1162
|
|
|
1103
1163
|
// AUTH-58: --client lists that one slug's cached identities; omitting it surveys every client
|