From 7289f78d0bbab1183767d526c53b836c04aceabb Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Sat, 26 Sep 2026 08:12:02 +0000 Subject: [PATCH] fix(oauth): keep Meta Muse login continuations current --- .../src/content/docs/guides/providers.md | 11 ++- gui/src/components/login-url-block.tsx | 5 +- gui/src/components/use-add-provider-oauth.ts | 15 +++- gui/src/pages/providers-shared.ts | 1 + gui/src/pages/use-providers-oauth.ts | 3 + .../add-codex-account-device-code.test.tsx | 1 + .../add-provider-oauth-url-leak.test.tsx | 41 +++++++++- .../provider-auth-device-code-copy.test.tsx | 11 +++ src/oauth/index.ts | 16 +++- src/oauth/login-flow-state.ts | 8 +- src/server/management/oauth-account-routes.ts | 10 ++- structure/dashboard-and-usage.md | 3 + .../ADR-5877-oauth-login-continuations.md | 24 ++++++ structure/gui-and-management-api.md | 23 ++++++ structure/providers-and-adapters.md | 3 + tests/oauth/oauth-public-surface.test.ts | 79 +++++++++++++++++++ 16 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 structure/decisions/ADR-5877-oauth-login-continuations.md diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 54372cce2f9..7e0d6ef075d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -284,8 +284,10 @@ desktop and the wrong one in two common cases: you need a different browser prof identity, a second account), or the dashboard is open against a proxy running somewhere else. Every login surface shows the authorization URL with a copy button, the device code when the -provider issues one, and a field to paste the redirect URL or authorization code back. So you can -always finish a login by hand. +provider issues one, and the current instructions. Browser callback flows also show a field to +paste the redirect URL or authorization code back. During device approval that field is hidden: +enter the displayed code on the provider's verification page instead. If the provider switches +to manual input, the dashboard replaces the old code and instructions on its next status poll. To stop the proxy from opening a browser at all, tick **Don't open a browser on the proxy machine** beside the login button, or set it permanently: @@ -302,7 +304,7 @@ Two cases behave differently, and it is worth knowing which you are in: - **A different browser profile on the same machine** works with the copied link alone. The loopback callback on `127.0.0.1` still completes the flow. -- **A browser on a different machine** also needs the paste fallback, because the redirect URI is +- **A browser callback flow on a different machine** also needs the paste fallback, because the redirect URI is still `http://127.0.0.1:/callback` on the proxy's host. Finish the login there, then paste the redirect URL (or just the code) back into the dashboard or `ocx account code`. @@ -766,6 +768,9 @@ including add-account and reauthentication. A raw admin token or forged GUI head `403 oauth_consent_required` before a credential is read or a grant starts. This gate uses the server-resolved session principal, not a separately recorded warning-checkbox receipt. Direct `ocx login meta-muse` and other OAuth providers keep their existing login policies. +The management OAuth provider list therefore omits Meta Muse for raw-admin-token dashboards; +open a session-authenticated dashboard to use that login flow. This changes discovery only, +not the admission checks on login start or manual continuation. Both seeded `meta-muse` models expose `minimal`/`low`/`medium`/`high`/`xhigh`/`max` to routed clients, including Grok's effort picker. Requests use diff --git a/gui/src/components/login-url-block.tsx b/gui/src/components/login-url-block.tsx index 6f9d30f85cb..a56227954d0 100644 --- a/gui/src/components/login-url-block.tsx +++ b/gui/src/components/login-url-block.tsx @@ -86,7 +86,8 @@ export type LoginHintPaste = { * * Order is deliberate: the device code first because it is the short thing a * human has to type, then the URL, then any provider prose, then the paste - * fallback for when the browser cannot reach the loopback callback. + * fallback for when the browser cannot reach the loopback callback. Device + * grants poll for approval instead: their human code is not a callback code. */ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginHintPaste }) { const t = useT(); @@ -119,7 +120,7 @@ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginH )} {hint.instructions &&
{hint.instructions}
} - {paste && ( + {paste && !deviceCode && (
{t("prov.pasteRedirectHint")}
diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index b28fa4473c4..185e7f8ea55 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -3,6 +3,7 @@ import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; import { afterOAuthCancellation, cancelOAuthLogin } from "../oauth-cancellation-barrier"; +import type { LoginHintData } from "./login-url-block"; export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; @@ -123,7 +124,7 @@ export function useAddProviderOAuth({ await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); if (!aliveRef.current || !isCurrent()) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); - const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; + const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string; hint?: LoginHintData }>(sRes) : null; if (!aliveRef.current || !isCurrent()) return; if (s?.error) { activeProvidersRef.current.delete(providerId); @@ -133,9 +134,16 @@ export function useAddProviderOAuth({ } if (s?.loggedIn) { activeProvidersRef.current.delete(providerId); + setOauthMsg(""); onAdded(providerId); return; } + if (s?.hint) { + setOauthUrl(s.hint.url ?? "", providerId, s.hint.deviceCode, s.hint.instructions); + setOauthMsg(s.hint.url || s.hint.deviceCode + ? t("modal.waitingLogin") + : (s.hint.instructions || t("modal.loggingIn"))); + } } await cancelServerLogin(providerId); if (!aliveRef.current || !isCurrent()) return; @@ -150,7 +158,10 @@ export function useAddProviderOAuth({ setOauthMsg(t("modal.networkError")); } } finally { - if (aliveRef.current && isCurrent()) setOauthBusy(false); + if (aliveRef.current && isCurrent()) { + setOauthBusy(false); + setOauthUrl("", providerId); + } } }, [aliveRef, apiBase, bumpLoginGeneration, cancelServerLogin, onAdded, t]); diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 99c9418a355..3408690f335 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -26,6 +26,7 @@ export interface OAuthStatus { email?: string; error?: string; done?: boolean; + hint?: import("../components/login-url-block").LoginHintData; needsReauth?: boolean; activeAccountId?: string | null; } diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index d97d28dfc0c..4a68c8b9c1a 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -199,6 +199,9 @@ export function useProvidersOAuth({ finished = true; break; } + // A later provider step replaces the initial POST hint (including an + // absent device code); generation checks above keep old polls out. + if (s.hint) setLoginInfo({ provider, url: s.hint.url, instructions: s.hint.instructions, deviceCode: s.hint.deviceCode }); } if (!finished && oauthLoginGenerationRef.current!.get(provider) === generation && aliveRef.current) { await cancelServerLogin(provider); diff --git a/gui/tests/add-codex-account-device-code.test.tsx b/gui/tests/add-codex-account-device-code.test.tsx index 64f139d7e9f..da8cde355ec 100644 --- a/gui/tests/add-codex-account-device-code.test.tsx +++ b/gui/tests/add-codex-account-device-code.test.tsx @@ -141,6 +141,7 @@ test("a device login renders the short code, not just the verification URL", asy expect(code).toBeTruthy(); expect(code?.textContent).toBe(DEVICE_CODE); expect(host.textContent).toContain(DEVICE_URL); + expect(host.querySelector(".login-hint-paste")).toBeNull(); }); test("the default browser flow does not ask for a device login", async () => { diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 347e314b2de..8b558fe5f33 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -149,6 +149,8 @@ function ProvidersOAuthHarness({ provider = "orcarouter-oauth", apiBase = "", on {busy ?? "idle"} {loginInfo?.url ?? "no-login-info"} + {loginInfo?.deviceCode ?? ""} + {loginInfo?.instructions ?? ""}