Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:<port>/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`.

Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions gui/src/components/login-url-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -119,7 +120,7 @@ export function LoginHint({ hint, paste }: { hint: LoginHintData; paste?: LoginH
)}
<LoginUrlBlock url={url} />
{hint.instructions && <div className="muted text-label">{hint.instructions}</div>}
{paste && (
{paste && !deviceCode && (
<div className="login-hint-paste">
<div className="muted text-label">{t("prov.pasteRedirectHint")}</div>
<div className="login-hint-paste-row">
Expand Down
15 changes: 13 additions & 2 deletions gui/src/components/use-add-provider-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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]);

Expand Down
1 change: 1 addition & 0 deletions gui/src/pages/providers-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions gui/src/pages/use-providers-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions gui/tests/add-codex-account-device-code.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
41 changes: 40 additions & 1 deletion gui/tests/add-provider-oauth-url-leak.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ function ProvidersOAuthHarness({ provider = "orcarouter-oauth", apiBase = "", on
<button onClick={() => { void cancelLoginOAuth(provider); }}>Cancel login</button>
<span data-testid="oauth-busy">{busy ?? "idle"}</span>
<span data-testid="oauth-login-info">{loginInfo?.url ?? "no-login-info"}</span>
<span data-testid="oauth-device-code">{loginInfo?.deviceCode ?? ""}</span>
<span data-testid="oauth-instructions">{loginInfo?.instructions ?? ""}</span>
<button
type="button"
disabled={busy === provider}
Expand Down Expand Up @@ -561,6 +563,39 @@ function raceServer() {
}

for (const surface of ["providers", "modal"] as const) {
test(`${surface}: polling replaces a device hint with the current manual continuation`, async () => {
const server = raceServer();
try {
await mountRaceSurface(surface);
await server.answerLogin(0, A_URL);
server.holdStatus(Promise.resolve(Response.json({ loggedIn: false, hint: {
url: B_URL, deviceCode: "ABCD-EFGH", instructions: "Approve this device",
} })));
await server.tick();
expect(host.textContent).toContain(B_URL);
expect(host.textContent).not.toContain(A_URL);
expect(host.textContent).toContain("ABCD-EFGH");
expect(host.textContent).toContain("Approve this device");
if (surface === "modal") expect(host.querySelector(".login-hint-paste")).toBeNull();

server.holdStatus(Promise.resolve(Response.json({ loggedIn: false, hint: {
url: A_URL, instructions: "Use the manual fallback",
} })));
await server.tick();
expect(host.textContent).toContain(A_URL);
expect(host.textContent).not.toContain(B_URL);
expect(host.textContent).not.toContain("ABCD-EFGH");
expect(host.textContent).not.toContain("Approve this device");
expect(host.textContent).toContain("Use the manual fallback");
if (surface === "modal") expect(host.querySelector(".login-hint-paste-input")).not.toBeNull();

server.holdStatus(undefined);
await server.finish();
expect(host.textContent).not.toContain("Use the manual fallback");
expect(host.textContent).not.toContain(A_URL);
} finally { await server.dispose(); }
});

for (const trigger of ["pagehide", "remount", "explicit"] as const) {
test(`F2 ${surface}: ${trigger} waits for cancel delivery and replacement completes`, async () => {
const server = raceServer();
Expand Down Expand Up @@ -743,9 +778,13 @@ for (const surface of ["providers", "modal"] as const) {
await server.deliverCancel();
await server.answerLogin(1, B_URL);
server.holdStatus(undefined);
await act(async () => { status.resolve(Response.json({ loggedIn: true, done: true })); });
await act(async () => { status.resolve(Response.json({ loggedIn: true, done: true, hint: {
url: A_URL, deviceCode: "STALE-CODE", instructions: "Stale login instructions",
} })); });
expect(settled).toEqual([]);
expect(host.textContent).toContain(B_URL);
expect(host.textContent).not.toContain("STALE-CODE");
expect(host.textContent).not.toContain("Stale login instructions");
await server.finish();
expect(settled).toEqual(["claude"]);
} finally {
Expand Down
11 changes: 11 additions & 0 deletions gui/tests/provider-auth-device-code-copy.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,24 @@ test("shows the device code and copies it", async () => {
await mountPanel({ provider: "claude", deviceCode: DEVICE_CODE });

expect(host.textContent).toContain(DEVICE_CODE);
expect(host.querySelector(".login-hint-paste")).toBeNull();

await clickCopy();

expect(clipboardWrites).toEqual([DEVICE_CODE]);
expect(host.textContent).toContain("Code copied");
});

test("a manual fallback replaces device instructions and restores the callback paste field", async () => {
await mountPanel({ provider: "claude", url: "https://auth.example.test/device", deviceCode: DEVICE_CODE });
expect(host.querySelector(".login-hint-paste")).toBeNull();

await mountPanel({ provider: "claude", url: "https://auth.example.test/manual", instructions: "Complete manual login" });
expect(host.querySelector(".pwi-device-code")).toBeNull();
expect(host.querySelector(".login-hint-paste-input")).not.toBeNull();
expect(host.textContent).toContain("Complete manual login");
});

test("copies through the legacy path in a non-secure context", async () => {
useClipboard(false);
const execCommand = mock(() => true);
Expand Down
16 changes: 12 additions & 4 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { resolveProviderTransport } from "../providers/xai-transport";
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
import { logOAuthEvent } from "./log";
import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper";
import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode } from "./login-flow-state";
import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode, type OAuthLoginHint } from "./login-flow-state";
export { reconcileOAuthFlowState, submitManualLoginCode } from "./login-flow-state";
import { randomUUID } from "node:crypto";
export {
Expand Down Expand Up @@ -1764,7 +1764,7 @@ export interface OAuthAccountSummary {
* the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
* The default masks, so every existing caller keeps today's behaviour.
*/
export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; hint?: OAuthLoginHint; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
const cred = getCredential(provider);
const st = loginState.get(provider);
const set = getAccountSet(provider);
Expand Down Expand Up @@ -1795,6 +1795,7 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn:
source: cred?.source,
error: st?.error,
done: st?.done ?? false,
...(st?.hint && !st.done ? { hint: { url: st.hint.url, instructions: st.hint.instructions, deviceCode: st.hint.deviceCode } } : {}),
...(set ? { activeAccountId: set.activeAccountId, accounts } : {}),
};
}
Expand Down Expand Up @@ -1849,8 +1850,15 @@ export async function startLoginFlow(
let urlResolved = false;
const ctrl: OAuthController = {
onAuth: ({ url, instructions, deviceCode }) => {
urlResolved = true;
resolve({ url, instructions, deviceCode });
if (abort.signal.aborted || loginAbort.get(provider)?.controller !== abort) return;
// Device approval can fall back to manual input. Replace, never merge: the
// previous device code must disappear when the provider changes the next step.
const hint = { url, instructions, deviceCode };
loginState.set(provider, { done: false, hint });
if (!urlResolved) {
urlResolved = true;
resolve({ ...hint });
}
},
onProgress: () => {},
// GUI fallback when the browser cannot hit the loopback callback server.
Expand Down
8 changes: 7 additions & 1 deletion src/oauth/login-flow-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import type { GenerationContext } from "../lib/state-store-sweeper";
* a login that has started and not yet settled, and none of it reads or writes a stored
* credential. `index.ts` re-exports the two public names, so existing importers are unaffected.
*/
export const loginState = new Map<string, { error?: string; done: boolean }>();
/** Human-facing continuation only: never retain credentials or provider response objects. */
export interface OAuthLoginHint {
url: string;
instructions?: string;
deviceCode?: string;
}
export const loginState = new Map<string, { error?: string; done: boolean; hint?: OAuthLoginHint }>();
export const loginAbort = new Map<string, { controller: AbortController; flowId?: string }>();
export const kiroLoginSettling = new Set<string>();

Expand Down
10 changes: 8 additions & 2 deletions src/server/management/oauth-account-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,12 @@ export function revokeApiKeyInProcess(config: OcxConfig, id: string): boolean {
return true;
}

function canStartManagementOAuth(provider: string, principal: ManagementContext["principal"]): boolean {
return provider !== "meta-muse" || principal === "gui-session";
}

function metaMuseConsentRequired(provider: string, principal: ManagementContext["principal"]): Response | null {
if (provider !== "meta-muse" || principal === "gui-session") return null;
if (canStartManagementOAuth(provider, principal)) return null;
return jsonResponse({
error: "Meta Muse login requires acknowledgement in the OpenCodex dashboard.",
code: "oauth_consent_required",
Expand All @@ -204,7 +208,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<

// Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
return jsonResponse({ providers: listOAuthProviders() });
// Discovery reflects this principal's admission; hiding a button is not the
// consent boundary, which remains independently enforced on both POST routes.
return jsonResponse({ providers: listOAuthProviders().filter(provider => canStartManagementOAuth(provider, principal)) });
}

// API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker.
Expand Down
3 changes: 3 additions & 0 deletions structure/dashboard-and-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ that tested contract without disabling the rule for other calls.
Rail selection is component-local state today, so a reload returns to the workspace's default
selection rather than the previously selected row. An OAuth ToS warning is shown before a login that
requires acceptance (`gui/src/components/OAuthTosWarningModal.tsx`).
Provider-login polling follows the [current continuation contract](gui-and-management-api.md#oauth-login-continuations):
device approval shows its code and verification link without a callback paste field; a later manual
step replaces that hint and restores paste. Discovery reflects the current management principal.

The `/#codex-auth` add-account modal has a three-step manual-code UX contract on top of the existing
OAuth polling API: submit request, waiting-for-login completion, and terminal success/failure. Once
Expand Down
24 changes: 24 additions & 0 deletions structure/decisions/ADR-5877-oauth-login-continuations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# ADR-5877 — decision recorded under "OAuth login continuations"

- Contract owner: [gui-and-management-api.md](../gui-and-management-api.md#oauth-login-continuations)

## Decision record

- 목적과 의도: Keep management login discovery usable for the current principal and display the
provider's current next step, including a device grant that falls back to manual input.
- 기존 구현 및 제약 조건: Meta Muse already requires a GUI-session principal at both management
POST boundaries; a raw admin token is not consent. The first `onAuth` resolves the start request,
so later callbacks cannot change that HTTP response. A device code is human-facing approval
material, not a redirect code for the loopback callback parser.
- 검토한 주요 대안: Relax the existing admission rule, let the first hint remain authoritative,
add a separate event stream, or project the latest transient hint through existing status polls.
- 선택한 방식: Share the existing admission predicate with discovery; retain only the three
human-facing hint fields and replace them on each current-flow callback. Project them through
status polling, preserve GUI generation guards, and hide callback paste during device approval.
- 다른 대안 대신 이 방식을 선택한 이유: It fixes the offered-but-forbidden action without widening
authority, and uses the polling lifecycle already owned by each screen. Complete replacement
removes stale device codes when a provider switches to manual continuation.
- 장점, 단점 및 영향: No dependency, persistent state or migration is introduced. The current hint
becomes visible on the next poll rather than immediately; credentials remain outside the DTO.
Cancellation and settlement clear transient hints, and late callbacks cannot resurrect them.
Direct CLI login remains governed by its existing policy, not management discovery.
Loading
Loading