@mathismeadows/roamer-device-auth 1.5.9 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathismeadows/roamer-device-auth",
3
- "version": "1.5.9",
3
+ "version": "1.5.10",
4
4
  "private": false,
5
5
  "mcpName": "com.mathismeadows/roamer-mcp",
6
6
  "type": "module",
@@ -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
- log("Registering as a new OAuth client...");
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,