@mathismeadows/roamer-device-auth 1.5.8 → 1.5.10
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 +78 -17
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathismeadows/roamer-device-auth",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"mcpName": "com.mathismeadows/roamer-mcp",
|
|
6
6
|
"type": "module",
|
|
7
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
|
-
"roamer-device-auth": "
|
|
9
|
+
"roamer-device-auth": "roamer-device-auth.mjs"
|
|
10
10
|
},
|
|
11
11
|
"main": "./roamer-device-auth.mjs",
|
|
12
12
|
"files": [
|
package/roamer-device-auth.mjs
CHANGED
|
@@ -462,6 +462,18 @@ async function writeLoopbackClientInfo(clientSlug, info, issuerUrl) {
|
|
|
462
462
|
await writeFile(path, JSON.stringify(stamped, null, 2), { mode: 0o600 });
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
+
// Found live 2026-09-11: a client_id registered before the authorization server started
|
|
466
|
+
// persisting/validating DCR registrations (e.g. before AUTH-42) can be cached here
|
|
467
|
+
// indefinitely under the SAME issuerUrl — readLoopbackClientInfo's own issuer check above
|
|
468
|
+
// only invalidates on an actual AS migration, not on a server-side tightening like AUTH-42
|
|
469
|
+
// that invalidates old client_ids without changing the issuer at all. Paired with the
|
|
470
|
+
// retry-once-with-a-fresh-registration logic in doGetValidTokensLoopback below.
|
|
471
|
+
function clearLoopbackClientInfo(clientSlug) {
|
|
472
|
+
const path = cacheFilePath("roamer_loopback_client", clientSlug);
|
|
473
|
+
if (!path) return Promise.resolve();
|
|
474
|
+
return unlink(path).catch(() => {});
|
|
475
|
+
}
|
|
476
|
+
|
|
465
477
|
// AUTH-14: which auth mechanism a given machine uses. Safari-default clients (macOS only —
|
|
466
478
|
// Safari isn't an option elsewhere) take the device-code path above, since Safari's
|
|
467
479
|
// HTTPS-Only Mode blocks the loopback callback outright. Everything else, including every
|
|
@@ -756,6 +768,27 @@ function waitForLoopbackCallback(server) {
|
|
|
756
768
|
});
|
|
757
769
|
}
|
|
758
770
|
|
|
771
|
+
// Found live 2026-09-11: when RoamerMcp's own /oauth/authorize rejects a client_id (e.g.
|
|
772
|
+
// invalid_client for one registered before AUTH-42 started persisting/validating
|
|
773
|
+
// registrations), it renders that error directly in the browser per AUTH-42's own "never
|
|
774
|
+
// redirect on failure" security rule — the browser never comes back to this loopback
|
|
775
|
+
// server at all. Without this timeout, callbackPromise above would then hang forever with
|
|
776
|
+
// zero visibility, since nothing else ever completes or fails. This is the one signal this
|
|
777
|
+
// process can observe for that entire failure class (a rejected authorize response looks
|
|
778
|
+
// identical, from here, to the user simply never finishing the browser flow).
|
|
779
|
+
const LOOPBACK_AUTHORIZE_TIMEOUT_MS = 120_000;
|
|
780
|
+
|
|
781
|
+
function withLoopbackTimeout(promise) {
|
|
782
|
+
let timer;
|
|
783
|
+
const timeout = new Promise((_, reject) => {
|
|
784
|
+
timer = setTimeout(
|
|
785
|
+
() => reject(new Error("LOOPBACK_AUTHORIZE_TIMEOUT: no callback received")),
|
|
786
|
+
LOOPBACK_AUTHORIZE_TIMEOUT_MS,
|
|
787
|
+
);
|
|
788
|
+
});
|
|
789
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
790
|
+
}
|
|
791
|
+
|
|
759
792
|
// AUTH-14: must follow the real WWW-Authenticate resource_metadata hint from a live 401,
|
|
760
793
|
// not RoamerMcp's own default .well-known/oauth-protected-resource document, which still
|
|
761
794
|
// mirrors Entra directly and would send a standards-compliant client to the wrong
|
|
@@ -812,6 +845,7 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogi
|
|
|
812
845
|
// it identifies this app instance to the authorization server, not the person using it, so
|
|
813
846
|
// every identity signing in from the same client-slug reuses the same registered client_id.
|
|
814
847
|
let clientInformation = await readLoopbackClientInfo(clientSlug, issuerUrl);
|
|
848
|
+
const clientInformationWasCached = Boolean(clientInformation);
|
|
815
849
|
|
|
816
850
|
if (!forceRefresh && !forceFreshLogin && tokens?.refresh_token && clientInformation) {
|
|
817
851
|
try {
|
|
@@ -828,23 +862,49 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogi
|
|
|
828
862
|
if (forceRefresh && identityKey) await clearLoopbackTokens(clientSlug, identityKey);
|
|
829
863
|
|
|
830
864
|
if (!clientInformation) {
|
|
831
|
-
|
|
832
|
-
// AUTH-51: the registered client_name carries the connecting client's own identity when
|
|
833
|
-
// known, so a future server-side session list/audit view could actually tell clients
|
|
834
|
-
// apart instead of seeing the same generic name for every stdio-bridge user.
|
|
835
|
-
clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
|
|
836
|
-
metadata: serverInfo.authorizationServerMetadata,
|
|
837
|
-
clientMetadata: {
|
|
838
|
-
client_name: clientSlug ? `Roamer MCP (stdio bridge — ${clientSlug})` : "Roamer MCP (stdio bridge)",
|
|
839
|
-
redirect_uris: [LOOPBACK_REDIRECT_URI],
|
|
840
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
841
|
-
response_types: ["code"],
|
|
842
|
-
token_endpoint_auth_method: "none",
|
|
843
|
-
},
|
|
844
|
-
});
|
|
845
|
-
await writeLoopbackClientInfo(clientSlug, clientInformation, issuerUrl);
|
|
865
|
+
clientInformation = await registerFreshLoopbackClient(serverInfo, clientSlug, issuerUrl);
|
|
846
866
|
}
|
|
847
867
|
|
|
868
|
+
// Found live 2026-09-11: a cached client_id can be rejected at authorize-time (invalid_client)
|
|
869
|
+
// for reasons that have nothing to do with the current issuer — e.g. AUTH-42 requiring
|
|
870
|
+
// registrations to be persisted/validated, applied after this client_id was already cached.
|
|
871
|
+
// performLoopbackInteractiveSignIn can't distinguish that from the user simply never
|
|
872
|
+
// finishing the browser flow (see LOOPBACK_AUTHORIZE_TIMEOUT_MS's own comment), so on ANY
|
|
873
|
+
// failure here, if the client_id in use came from cache rather than a fresh registration
|
|
874
|
+
// this same call already did, assume it might be stale, clear it, register a brand new one,
|
|
875
|
+
// and retry the interactive flow exactly once — never a second time, so a genuinely broken
|
|
876
|
+
// server or a real user cancellation still surfaces as an error instead of looping.
|
|
877
|
+
try {
|
|
878
|
+
return await performLoopbackInteractiveSignIn(serverInfo, clientInformation, clientSlug, issuerUrl);
|
|
879
|
+
} catch (err) {
|
|
880
|
+
if (!clientInformationWasCached) throw err;
|
|
881
|
+
log(`Sign-in failed with a cached OAuth client (${err.message}). Re-registering and retrying once...`);
|
|
882
|
+
await clearLoopbackClientInfo(clientSlug);
|
|
883
|
+
clientInformation = await registerFreshLoopbackClient(serverInfo, clientSlug, issuerUrl);
|
|
884
|
+
return await performLoopbackInteractiveSignIn(serverInfo, clientInformation, clientSlug, issuerUrl);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function registerFreshLoopbackClient(serverInfo, clientSlug, issuerUrl) {
|
|
889
|
+
log("Registering as a new OAuth client...");
|
|
890
|
+
// AUTH-51: the registered client_name carries the connecting client's own identity when
|
|
891
|
+
// known, so a future server-side session list/audit view could actually tell clients
|
|
892
|
+
// apart instead of seeing the same generic name for every stdio-bridge user.
|
|
893
|
+
const clientInformation = await registerClient(serverInfo.authorizationServerUrl, {
|
|
894
|
+
metadata: serverInfo.authorizationServerMetadata,
|
|
895
|
+
clientMetadata: {
|
|
896
|
+
client_name: clientSlug ? `Roamer MCP (stdio bridge — ${clientSlug})` : "Roamer MCP (stdio bridge)",
|
|
897
|
+
redirect_uris: [LOOPBACK_REDIRECT_URI],
|
|
898
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
899
|
+
response_types: ["code"],
|
|
900
|
+
token_endpoint_auth_method: "none",
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
await writeLoopbackClientInfo(clientSlug, clientInformation, issuerUrl);
|
|
904
|
+
return clientInformation;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function performLoopbackInteractiveSignIn(serverInfo, clientInformation, clientSlug, issuerUrl) {
|
|
848
908
|
log("Starting sign-in...");
|
|
849
909
|
// AUTH-38: no `resource` parameter — that was a Cloudflare Access Managed OAuth-specific
|
|
850
910
|
// requirement (RFC 8707; it rejected the request without one). RoamerMcp's own /oauth/authorize
|
|
@@ -882,7 +942,7 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogi
|
|
|
882
942
|
|
|
883
943
|
let authorizationCode;
|
|
884
944
|
try {
|
|
885
|
-
authorizationCode = await callbackPromise;
|
|
945
|
+
authorizationCode = await withLoopbackTimeout(callbackPromise);
|
|
886
946
|
} finally {
|
|
887
947
|
server.close();
|
|
888
948
|
}
|
|
@@ -894,7 +954,7 @@ async function doGetValidTokensLoopback(clientSlug, forceRefresh, forceFreshLogi
|
|
|
894
954
|
codeVerifier,
|
|
895
955
|
redirectUri: LOOPBACK_REDIRECT_URI,
|
|
896
956
|
});
|
|
897
|
-
tokens = { ...fresh, obtained_at: Date.now() };
|
|
957
|
+
const tokens = { ...fresh, obtained_at: Date.now() };
|
|
898
958
|
const freshIdentityKey = identityKeyFromTokens(tokens);
|
|
899
959
|
const freshLabel = identityLabelFromTokens(tokens);
|
|
900
960
|
await writeLoopbackTokens(clientSlug, tokens, issuerUrl, freshIdentityKey);
|
|
@@ -1384,6 +1444,7 @@ export {
|
|
|
1384
1444
|
writeLoopbackTokens,
|
|
1385
1445
|
readLoopbackClientInfo,
|
|
1386
1446
|
writeLoopbackClientInfo,
|
|
1447
|
+
clearLoopbackClientInfo,
|
|
1387
1448
|
// AUTH-54/55/57/58
|
|
1388
1449
|
cacheFilePath,
|
|
1389
1450
|
identityKeyFromTokens,
|