@mathismeadows/roamer-device-auth 1.5.2 → 1.5.4
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 +114 -12
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
|
|
@@ -865,6 +906,37 @@ async function forwardLine(transport, line, getTokens, setTokens, getFreshTokens
|
|
|
865
906
|
}
|
|
866
907
|
}
|
|
867
908
|
|
|
909
|
+
// AUTH-60: a reserved, locally-answered resource — never forwarded to the real upstream
|
|
910
|
+
// server. Lets the exact connection asking report its own resolved clientSlug/identityLabel
|
|
911
|
+
// back through the MCP session itself, with no external file or PID correlation required
|
|
912
|
+
// (see AUTH-60's notes for why a file-based "list live connections" design was rejected).
|
|
913
|
+
function isWhoamiResourceRead(line) {
|
|
914
|
+
try {
|
|
915
|
+
const message = JSON.parse(line);
|
|
916
|
+
return message?.method === "resources/read" && message?.params?.uri === "roamer://whoami";
|
|
917
|
+
} catch {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function respondWhoami(line, clientSlug, identityLabel) {
|
|
923
|
+
let id = null;
|
|
924
|
+
try {
|
|
925
|
+
id = JSON.parse(line)?.id ?? null;
|
|
926
|
+
} catch {
|
|
927
|
+
// Malformed input never had a usable id anyway — respond with null per JSON-RPC convention.
|
|
928
|
+
}
|
|
929
|
+
const payload = { clientSlug: clientSlug ?? null, identityLabel: identityLabel ?? null };
|
|
930
|
+
const response = {
|
|
931
|
+
jsonrpc: "2.0",
|
|
932
|
+
id,
|
|
933
|
+
result: {
|
|
934
|
+
contents: [{ uri: "roamer://whoami", mimeType: "application/json", text: JSON.stringify(payload, null, 2) }],
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
938
|
+
}
|
|
939
|
+
|
|
868
940
|
// AUTH-28: a hard sign-in failure (denied/expired device code) must tell the host exactly
|
|
869
941
|
// that, per request, rather than leaving requests unanswered for the host to time out on.
|
|
870
942
|
function respondWithSignInError(line, err) {
|
|
@@ -893,6 +965,13 @@ async function main() {
|
|
|
893
965
|
let ready = false;
|
|
894
966
|
const pendingLines = [];
|
|
895
967
|
|
|
968
|
+
// AUTH-60: identityLabel is resolved independently of `ready` (well before transport
|
|
969
|
+
// connects — see identityResolved below), so roamer://whoami can answer even while the
|
|
970
|
+
// real upstream connection is still coming up.
|
|
971
|
+
let identityLabel = null;
|
|
972
|
+
let identityResolved = false;
|
|
973
|
+
const pendingWhoamiLines = [];
|
|
974
|
+
|
|
896
975
|
// AUTH-51: which client is asking, resolved once from the first stdin line (the MCP
|
|
897
976
|
// `initialize` request, always message #1 by protocol) before any credential is touched.
|
|
898
977
|
let clientSlug = null;
|
|
@@ -929,6 +1008,12 @@ async function main() {
|
|
|
929
1008
|
clientSlug = clientSlugFromInitializeLine(line);
|
|
930
1009
|
resolveFirstLine();
|
|
931
1010
|
}
|
|
1011
|
+
// AUTH-60: answered locally from this process's own state, never forwarded upstream.
|
|
1012
|
+
if (isWhoamiResourceRead(line)) {
|
|
1013
|
+
if (identityResolved) respondWhoami(line, clientSlug, identityLabel);
|
|
1014
|
+
else pendingWhoamiLines.push(line);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
932
1017
|
if (ready) {
|
|
933
1018
|
forwardLine(transport, line, getTokens, setTokens, freshTokensForForward);
|
|
934
1019
|
} else {
|
|
@@ -972,18 +1057,23 @@ async function main() {
|
|
|
972
1057
|
freshTokensForForward = (force) => getFreshTokens(clientSlug, force).then((result) => result.tokens);
|
|
973
1058
|
|
|
974
1059
|
let reusedSilently = false;
|
|
975
|
-
let identityLabel = null;
|
|
976
1060
|
try {
|
|
977
1061
|
const result = await getFreshTokens(clientSlug);
|
|
978
1062
|
tokens = result.tokens;
|
|
979
1063
|
reusedSilently = result.reusedSilently;
|
|
980
|
-
|
|
1064
|
+
// AUTH-59: live lookup on every run, bounded by fetchLiveLabel's own short timeout — this
|
|
1065
|
+
// adds a network round trip to every ordinary client startup in exchange for a readable
|
|
1066
|
+
// log line; falls back to the existing oid-based label on any failure.
|
|
1067
|
+
identityLabel = (await fetchLiveLabel(result.tokens?.access_token)) ?? result.label;
|
|
981
1068
|
} catch (err) {
|
|
982
1069
|
// Sign-in genuinely failed (denied/expired), not just slow — every message queued while
|
|
983
1070
|
// we waited, starting with `initialize`, gets a real JSON-RPC error so the host sees an
|
|
984
1071
|
// explicit failure instead of a process it has to silently time out on.
|
|
985
1072
|
log(`Sign-in failed: ${err.message}`);
|
|
986
1073
|
for (const line of pendingLines) respondWithSignInError(line, err);
|
|
1074
|
+
// AUTH-60: a whoami read queued during a sign-in that then fails can never get a real
|
|
1075
|
+
// answer — same failure treatment as every other queued line, not left hanging.
|
|
1076
|
+
for (const line of pendingWhoamiLines) respondWithSignInError(line, err);
|
|
987
1077
|
process.exit(1);
|
|
988
1078
|
}
|
|
989
1079
|
|
|
@@ -993,6 +1083,11 @@ async function main() {
|
|
|
993
1083
|
// instant a transient OS notification fired.
|
|
994
1084
|
log(identityLabel ? `Authenticated as ${identityLabel} for client "${clientSlug}".` : `Authenticated for client "${clientSlug}" (no identity claim found on the token).`);
|
|
995
1085
|
|
|
1086
|
+
// AUTH-60: identityLabel is now final — answer any roamer://whoami reads that arrived
|
|
1087
|
+
// before sign-in completed, the same way pendingLines get flushed once ready flips true.
|
|
1088
|
+
identityResolved = true;
|
|
1089
|
+
for (const line of pendingWhoamiLines) respondWhoami(line, clientSlug, identityLabel);
|
|
1090
|
+
|
|
996
1091
|
// AUTH-51: the moment that used to be completely silent — a process starting up and
|
|
997
1092
|
// immediately using a credential it never interactively obtained this run.
|
|
998
1093
|
if (reusedSilently) await notifySessionReused(clientSlug, identityLabel);
|
|
@@ -1067,7 +1162,8 @@ async function loginCommand(args) {
|
|
|
1067
1162
|
const getFreshTokens = isSafari ? getValidTokens : getValidTokensLoopback;
|
|
1068
1163
|
try {
|
|
1069
1164
|
const result = await getFreshTokens(clientSlug, false, /* forceFreshLogin */ true);
|
|
1070
|
-
|
|
1165
|
+
const displayLabel = (await fetchLiveLabel(result.tokens?.access_token)) ?? result.label;
|
|
1166
|
+
log(`Signed in as ${displayLabel ?? "(no identity claim found on the token)"} — now the active identity for "${clientSlug}".`);
|
|
1071
1167
|
} catch (err) {
|
|
1072
1168
|
log(`Login failed: ${err.message}`);
|
|
1073
1169
|
process.exitCode = 1;
|
|
@@ -1104,12 +1200,15 @@ async function logoutCommand(args) {
|
|
|
1104
1200
|
log(`"${clientSlug}" has no active identity to log out of.`);
|
|
1105
1201
|
return;
|
|
1106
1202
|
}
|
|
1203
|
+
// AUTH-59: resolved before deletion, from the very cache file about to be removed.
|
|
1204
|
+
const cachedTokens = await readAnyTokensForIdentity(clientSlug, active.identityKey);
|
|
1205
|
+
const displayLabel = (await fetchLiveLabel(cachedTokens?.access_token)) ?? active.label;
|
|
1107
1206
|
await Promise.all([
|
|
1108
1207
|
deleteCacheFile(cacheFilePath("roamer_tokens", clientSlug, active.identityKey)),
|
|
1109
1208
|
deleteCacheFile(cacheFilePath("roamer_loopback_tokens", clientSlug, active.identityKey)),
|
|
1110
1209
|
]);
|
|
1111
1210
|
await clearActiveIdentity(clientSlug);
|
|
1112
|
-
log(`Logged out ${
|
|
1211
|
+
log(`Logged out ${displayLabel} for "${clientSlug}". Its next launch will sign in fresh, exactly like a first-ever run.`);
|
|
1113
1212
|
}
|
|
1114
1213
|
|
|
1115
1214
|
// AUTH-58: --client lists that one slug's cached identities; omitting it surveys every client
|
|
@@ -1192,4 +1291,7 @@ export {
|
|
|
1192
1291
|
loginCommand,
|
|
1193
1292
|
logoutCommand,
|
|
1194
1293
|
statusCommand,
|
|
1294
|
+
// AUTH-60
|
|
1295
|
+
isWhoamiResourceRead,
|
|
1296
|
+
respondWhoami,
|
|
1195
1297
|
};
|