@mathismeadows/roamer-device-auth 1.5.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "private": false,
5
5
  "mcpName": "com.mathismeadows/roamer-mcp",
6
6
  "type": "module",
@@ -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
- return [...seen.entries()].map(([identityKey, label]) => ({
179
- identityKey,
180
- label,
181
- active: identityKey === active?.identityKey,
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
@@ -977,7 +1018,10 @@ async function main() {
977
1018
  const result = await getFreshTokens(clientSlug);
978
1019
  tokens = result.tokens;
979
1020
  reusedSilently = result.reusedSilently;
980
- identityLabel = result.label;
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;
981
1025
  } catch (err) {
982
1026
  // Sign-in genuinely failed (denied/expired), not just slow — every message queued while
983
1027
  // we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
@@ -1067,7 +1111,8 @@ async function loginCommand(args) {
1067
1111
  const getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
1068
1112
  try {
1069
1113
  const result = await getFreshTokens(clientSlug, false, /* forceFreshLogin */ true);
1070
- log(`Signed in as ${result.label ?? "(no identity claim found on the token)"} — now the active identity for "${clientSlug}".`);
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}".`);
1071
1116
  } catch (err) {
1072
1117
  log(`Login failed: ${err.message}`);
1073
1118
  process.exitCode = 1;
@@ -1104,12 +1149,15 @@ async function logoutCommand(args) {
1104
1149
  log(`"${clientSlug}" has no active identity to log out of.`);
1105
1150
  return;
1106
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;
1107
1155
  await Promise.all([
1108
1156
  deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, active.identityKey)),
1109
1157
  deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, active.identityKey)),
1110
1158
  ]);
1111
1159
  await clearActiveIdentity(clientSlug);
1112
- log(`Logged out ${active.label} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
1160
+ log(`Logged out ${displayLabel} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
1113
1161
  }
1114
1162
 
1115
1163
  // AUTH-58: --client lists that one slug's cached identities; omitting it surveys every client