From ff35a325360fa653f09d09b1daff4954d3e8620c Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:10:11 +0900 Subject: [PATCH 1/5] feat(gui): add manual paste fallback for OAuth add-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the existing /api/oauth/login/code path in the GUI: while a login is in progress the account panel shows a paste box that accepts a redirect URL / authorization code / raw Command Code API key (Command Code uses password masking). Keeps the server-side rotation/pool logic untouched — minimal surface to let users add a second Command Code account without fighting the localhost callback. GUI: ProviderAuthPanel + types + use-providers-oauth hook + Providers wiring, paste styles. i18n: en/de/ja/ko/ru/tr/zh/zh-TW (command-code placeholder + hint, plus the refined redirect hint from #1552). Test: provider-auth-manual-code (password type + role=status/alert feedback). --- .../provider-workspace/ProviderAuthPanel.tsx | 70 ++++++++++++ .../components/provider-workspace/types.ts | 2 + gui/src/i18n/de.ts | 4 +- gui/src/i18n/en.ts | 2 + gui/src/i18n/ja.ts | 4 +- gui/src/i18n/ko.ts | 4 +- gui/src/i18n/ru.ts | 4 +- gui/src/i18n/tr.ts | 4 +- gui/src/i18n/zh-TW.ts | 4 +- gui/src/i18n/zh.ts | 4 +- gui/src/pages/Providers.tsx | 3 +- gui/src/pages/use-providers-oauth.ts | 21 +++- .../styles/provider-workspace-settings.css | 1 + gui/tests/provider-auth-manual-code.test.tsx | 102 ++++++++++++++++++ 14 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 gui/tests/provider-auth-manual-code.test.tsx diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 928e15d93c..d8ed50555a 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -187,6 +187,10 @@ export default function ProviderAuthPanel({ const [addingKey, setAddingKey] = useState(false); const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); + const [manualCode, setManualCode] = useState(""); + const [manualCodeBusy, setManualCodeBusy] = useState(false); + const [manualCodeMsg, setManualCodeMsg] = useState(""); + const [manualCodeOk, setManualCodeOk] = useState(true); const [importBusy, setImportBusy] = useState(false); const [importStatus, setImportStatus] = useState<"idle" | "invalid" | "failed" | "complete">("idle"); const [importResult, setImportResult] = useState(null); @@ -263,6 +267,25 @@ export default function ProviderAuthPanel({ } }; + const submitManualCode = async () => { + const input = manualCode.trim(); + if (!input || manualCodeBusy || !authHandlers.onSubmitManualCode) return; + setManualCodeBusy(true); + setManualCodeMsg(""); + try { + await authHandlers.onSubmitManualCode(item.name, input); + setManualCode(""); + setManualCodeOk(true); + setManualCodeMsg(t("prov.pasteOk")); + } catch (error) { + setManualCodeOk(false); + const message = error instanceof Error && error.message.trim() ? error.message : t("prov.networkError"); + setManualCodeMsg(t("prov.pasteFail", { error: message })); + } finally { + setManualCodeBusy(false); + } + }; + const importCockpitFile = async (file: File | undefined) => { if (!file || importBusy) return; setImportBusy(true); @@ -400,6 +423,53 @@ export default function ProviderAuthPanel({ )} + {authHandlers.onSubmitManualCode && ( +
+
+ {item.name === "command-code" + ? t("prov.pasteCommandCodeHint") + : t("prov.pasteRedirectHint")} +
+
+ { setManualCode(e.target.value); setManualCodeMsg(""); }} + onKeyDown={e => { + if (e.key === "Enter" && manualCode.trim()) { + e.preventDefault(); + void submitManualCode(); + } + }} + placeholder={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + aria-label={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + disabled={manualCodeBusy} + className="input text-label" + style={{ flex: 1 }} + /> + +
+ {manualCodeMsg && ( +
+ {manualCodeMsg} +
+ )} +
+ )} {authHandlers.onCancelLogin && ( )} diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 147773fd93..2686a63585 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -390,6 +390,8 @@ export const fr: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "Coller l’URL de redirection ou le code", "prov.pasteRedirectHint": "Si le navigateur affiche une erreur localhost, copiez l’URL complète depuis sa barre d’adresse et collez-la ici (ou collez le code d’autorisation).", + "prov.pasteCommandCodePlaceholder": "Coller la clé API Command Code ou l’URL de redirection", + "prov.pasteCommandCodeHint": "Collez une clé API Command Code (user_… depuis ~/.commandcode/auth.json) pour l’ajouter comme autre compte, ou l’URL de redirection du navigateur.", "prov.pasteSubmit": "Envoyer", "prov.pasteSubmitting": "Envoi…", "prov.pasteOk": "Code envoyé — finalisation de la connexion…", diff --git a/gui/tests/provider-auth-manual-code.test.tsx b/gui/tests/provider-auth-manual-code.test.tsx index ed2aa56773..9714562839 100644 --- a/gui/tests/provider-auth-manual-code.test.tsx +++ b/gui/tests/provider-auth-manual-code.test.tsx @@ -99,4 +99,44 @@ test("masks pasted Command Code credentials and preserves rejection feedback", a await new Promise(resolve => setTimeout(resolve, 0)); }); expect(host.querySelector('[role="status"]')?.textContent).toContain("Code submitted — finishing login…"); + + // Ending the flow must clear the credential and its feedback even when the + // provider panel remains mounted for the next Add account attempt. + await act(async () => { + root!.render( + + {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submit, + }} + /> + , + ); + }); + expect(host.querySelector('input[type="password"]')).toBeNull(); + await act(async () => { + root!.render( + + {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submit, + }} + /> + , + ); + }); + expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(""); + expect(host.querySelector('[role="status"]')).toBeNull(); }); From 7eb9686bdfd5fbb98011eec27e5344afdbe41a54 Mon Sep 17 00:00:00 2001 From: dbc-hbin Date: Fri, 21 Aug 2026 18:08:59 +0900 Subject: [PATCH 4/5] fix(gui): harden manual OAuth paste flow --- .../provider-workspace/ProviderAuthPanel.tsx | 40 ++++++--- gui/tests/provider-auth-manual-code.test.tsx | 85 +++++++++++++++++++ tests/oauth-manual-code.test.ts | 8 +- 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 865294ec43..cd2975b08b 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -197,6 +197,12 @@ export default function ProviderAuthPanel({ const [reserveQuotaSlots, setReserveQuotaSlots] = useState(false); const importFileRef = useRef(null); const deviceCodeCopy = useCopyFeedback(); + const manualFlowKey = loginHint?.provider === item.name + ? `${loginHint.provider}\0${loginHint.url ?? ""}\0${loginHint.deviceCode ?? ""}` + : ""; + const [seenManualFlowKey, setSeenManualFlowKey] = useState(manualFlowKey); + const manualFlowKeyRef = useRef(manualFlowKey); + const mountedRef = useRef(false); const resetManualCode = () => { setManualCode(""); @@ -204,14 +210,21 @@ export default function ProviderAuthPanel({ setManualCodeOk(true); }; - // Login hints are removed when a flow ends and change when a new flow starts. - // Do not retain credential-bearing input or feedback across either boundary. - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect, react/react-compiler + if (manualFlowKey !== seenManualFlowKey) { + setSeenManualFlowKey(manualFlowKey); setManualCode(""); + setManualCodeBusy(false); setManualCodeMsg(""); setManualCodeOk(true); - }, [item.name, loginHint?.provider, loginHint?.url, loginHint?.deviceCode]); + } + + useEffect(() => { + mountedRef.current = true; + manualFlowKeyRef.current = manualFlowKey; + return () => { + mountedRef.current = false; + }; + }, [manualFlowKey]); // Soft "a=1 enrichment lands after the local account list. Reserve stacked // bar height briefly so bars don't shove rows when WHAM returns. @@ -239,6 +252,7 @@ export default function ProviderAuthPanel({ const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); const isOauth = surface === "oauth-accounts"; const isKeyAuth = surface === "api-keys"; + const isCommandCodeAuth = item.adapter === "command-code" && item.authMode === "oauth"; if (surface === "codex-accounts") { return ( @@ -285,19 +299,24 @@ export default function ProviderAuthPanel({ const submitManualCode = async () => { const input = manualCode.trim(); if (!input || manualCodeBusy || !authHandlers.onSubmitManualCode) return; + const submittedFlowKey = manualFlowKeyRef.current; setManualCodeBusy(true); setManualCodeMsg(""); try { await authHandlers.onSubmitManualCode(item.name, input); + if (!mountedRef.current || manualFlowKeyRef.current !== submittedFlowKey) return; setManualCode(""); setManualCodeOk(true); setManualCodeMsg(t("prov.pasteOk")); } catch (error) { + if (!mountedRef.current || manualFlowKeyRef.current !== submittedFlowKey) return; setManualCodeOk(false); const message = error instanceof Error && error.message.trim() ? error.message : t("prov.networkError"); setManualCodeMsg(t("prov.pasteFail", { error: message })); } finally { - setManualCodeBusy(false); + if (mountedRef.current && manualFlowKeyRef.current === submittedFlowKey) { + setManualCodeBusy(false); + } } }; @@ -441,15 +460,16 @@ export default function ProviderAuthPanel({ {authHandlers.onSubmitManualCode && (
- {item.name === "command-code" + {isCommandCodeAuth ? t("prov.pasteCommandCodeHint") : t("prov.pasteRedirectHint")}
{ setManualCode(e.target.value); setManualCodeMsg(""); }} onKeyDown={e => { @@ -458,8 +478,8 @@ export default function ProviderAuthPanel({ void submitManualCode(); } }} - placeholder={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} - aria-label={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + placeholder={isCommandCodeAuth ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + aria-label={isCommandCodeAuth ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} disabled={manualCodeBusy} className="input text-label" style={{ flex: 1 }} diff --git a/gui/tests/provider-auth-manual-code.test.tsx b/gui/tests/provider-auth-manual-code.test.tsx index 9714562839..11943a1b6c 100644 --- a/gui/tests/provider-auth-manual-code.test.tsx +++ b/gui/tests/provider-auth-manual-code.test.tsx @@ -70,6 +70,7 @@ test("masks pasted Command Code credentials and preserves rejection feedback", a const input = host.querySelector('input[type="password"]') as HTMLInputElement; expect(input).toBeTruthy(); expect(input.type).toBe("password"); + expect(input.maxLength).toBe(4096); await act(async () => { const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; setter.call(input, "user_secret"); @@ -140,3 +141,87 @@ test("masks pasted Command Code credentials and preserves rejection feedback", a expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(""); expect(host.querySelector('[role="status"]')).toBeNull(); }); + +test("identifies Command Code manual auth from the provider contract", async () => { + const { createRoot } = await import("react-dom/client"); + const aliasedItem: WorkspaceItem = { ...item, name: "command-code-work" }; + await act(async () => { + root = createRoot(host); + root.render( + + {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submit, + }} + /> + , + ); + }); + + const input = host.querySelector('input[type="password"]') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.getAttribute("aria-label")).toContain("Command Code API key"); +}); + +test("ignores completion from a stale manual-auth flow", async () => { + const { createRoot } = await import("react-dom/client"); + let resolveSubmit!: () => void; + const deferredSubmit = mock(() => new Promise(resolve => { resolveSubmit = resolve; })); + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: deferredSubmit, + }; + + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + const input = host.querySelector('input[type="password"]') as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "user_secret"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); + }); + expect(deferredSubmit).toHaveBeenCalledTimes(1); + + await act(async () => { + root!.render( + + + , + ); + }); + expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(""); + + await act(async () => { + resolveSubmit(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + expect(host.querySelector('[role="status"]')).toBeNull(); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect((host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).disabled).toBe(true); +}); diff --git a/tests/oauth-manual-code.test.ts b/tests/oauth-manual-code.test.ts index be2dde934e..346d1c7a8c 100644 --- a/tests/oauth-manual-code.test.ts +++ b/tests/oauth-manual-code.test.ts @@ -149,12 +149,12 @@ describe("OAuth manual login code fallback", () => { mismatch = submitManualLoginCode("xai", `${redirectUri}?code=evil&state=WRONG`); } expect(mismatch.ok).toBe(false); - if (!mismatch.ok) expect(mismatch.error).toContain("state mismatch"); + if (!mismatch.ok) expect(mismatch.error).toBe("state mismatch — paste the redirect URL from THIS login attempt"); // URL-shaped input with NO state is rejected, not downgraded to a raw code. const missingState = submitManualLoginCode("xai", `${redirectUri}?code=abc`); expect(missingState.ok).toBe(false); - if (!missingState.ok) expect(missingState.error).toContain("missing the state"); + if (!missingState.ok) expect(missingState.error).toBe("redirect URL is missing the state parameter"); // Correct paste: matching state completes the login via the original verifier. const goodSubmit = submitManualLoginCode("xai", `${redirectUri}?code=pasted-auth-code&state=${state}`); @@ -248,11 +248,11 @@ describe("OAuth manual login code fallback", () => { const oversized = await post({ provider: "xai", input: "x".repeat(5000) }); expect(oversized.status).toBe(400); - expect(((await oversized.json()) as { error?: string }).error).toContain("too long"); + expect(((await oversized.json()) as { error?: string }).error).toBe("input too long"); const noLogin = await post({ provider: "xai", input: "some-code" }); expect(noLogin.status).toBe(409); - expect(((await noLogin.json()) as { error?: string }).error).toContain("no login in progress"); + expect(((await noLogin.json()) as { error?: string }).error).toBe("no login in progress"); } finally { await server.stop(true); } From f87c4acb7ccfb72ab8677a9db30a1e5206e164aa Mon Sep 17 00:00:00 2001 From: dbc-hbin Date: Fri, 21 Aug 2026 19:05:22 +0900 Subject: [PATCH 5/5] fix(oauth): bind manual paste to attempt, include instructions in flow key - Server: startLoginFlow returns opaque attemptId per provider; loginState + loginAttemptId store it. submitManualLoginCode validates attemptId (stale/missing -> 409 stale login attempt) before state checks, so a delayed paste from attempt A cannot be consumed by replacement attempt B. POST /api/oauth/login now returns {attemptId} and /api/oauth/login/code requires it. cancel/clear and settle paths clear the attempt store. - Client: useProvidersOAuth stores attemptId per provider, sends it with manual code, aborts obsolete manual fetches on generation bump, and maps server errors to a fixed localized vocabulary (prov.manualError*) instead of rendering raw data.error/statusText. ProviderAuthPanel manualFlowKey now includes instructions and attemptId so credential-bearing state resets when the server changes only instructions or rotates the attempt. - i18n: pasteCommandCode placeholder/hint in all 9 locales now list the three accepted inputs (API key, auth code, redirect URL). - Tests: route and direct regression for cancel->restart->stale delayed submit (raw and URL); GUI regressions for instructions-only and attemptId rotation clearing input/feedback and ignoring stale completions. --- .../provider-workspace/ProviderAuthPanel.tsx | 5 +- .../components/provider-workspace/types.ts | 3 +- gui/src/i18n/de.ts | 12 +- gui/src/i18n/en.ts | 12 +- gui/src/i18n/fr.ts | 12 +- gui/src/i18n/ja.ts | 12 +- gui/src/i18n/ko.ts | 12 +- gui/src/i18n/ru.ts | 12 +- gui/src/i18n/tr.ts | 12 +- gui/src/i18n/zh-TW.ts | 12 +- gui/src/i18n/zh.ts | 12 +- gui/src/pages/Providers.tsx | 3 +- gui/src/pages/use-providers-oauth.ts | 73 +++- gui/tests/provider-auth-manual-code.test.tsx | 328 +++++++++++++++++- src/oauth/index.ts | 26 +- src/server/management/oauth-account-routes.ts | 9 +- tests/oauth-manual-code.test.ts | 101 +++++- 17 files changed, 605 insertions(+), 51 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index cd2975b08b..582f5ce97f 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -198,7 +198,7 @@ export default function ProviderAuthPanel({ const importFileRef = useRef(null); const deviceCodeCopy = useCopyFeedback(); const manualFlowKey = loginHint?.provider === item.name - ? `${loginHint.provider}\0${loginHint.url ?? ""}\0${loginHint.deviceCode ?? ""}` + ? `${loginHint.provider}\0${loginHint.url ?? ""}\0${loginHint.deviceCode ?? ""}\0${loginHint.instructions ?? ""}\0${loginHint.attemptId ?? ""}` : ""; const [seenManualFlowKey, setSeenManualFlowKey] = useState(manualFlowKey); const manualFlowKeyRef = useRef(manualFlowKey); @@ -303,7 +303,8 @@ export default function ProviderAuthPanel({ setManualCodeBusy(true); setManualCodeMsg(""); try { - await authHandlers.onSubmitManualCode(item.name, input); + const outcome = await authHandlers.onSubmitManualCode(item.name, input); + if (outcome !== "submitted") return; if (!mountedRef.current || manualFlowKeyRef.current !== submittedFlowKey) return; setManualCode(""); setManualCodeOk(true); diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 8bdbf16aa2..ae48a0ff6a 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -67,6 +67,7 @@ export type LoginHint = { url?: string; instructions?: string; deviceCode?: string; + attemptId?: string; }; export type AccountLoadState = "idle" | "loading" | "ready" | "error"; @@ -75,7 +76,7 @@ export interface ProviderAuthHandlers { onLogin: (provider: string, addAccount?: boolean) => void | Promise; onCancelLogin?: (provider: string) => void; /** Submit a pasted redirect URL / authorization code / raw API key to an in-progress login. */ - onSubmitManualCode?: (provider: string, input: string) => void | Promise; + onSubmitManualCode?: (provider: string, input: string) => Promise<"submitted" | "cancelled">; onLogout: (provider: string) => void | Promise; onReauth: (provider: string, accountId?: string) => void | Promise; onSwitchAccount: (provider: string, account: OAuthAccountRow) => void | Promise; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1c1ad1708b..44c8a524d0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -380,11 +380,19 @@ export const de: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "Redirect-URL oder Code einfügen", "prov.pasteRedirectHint": "Falls der Browser einen localhost-Fehler zeigt, kopieren Sie die vollständige URL aus der Adressleiste und fügen Sie sie hier ein (oder fügen Sie den Autorisierungscode ein).", - "prov.pasteCommandCodePlaceholder": "Command-Code-API-Schlüssel oder Redirect-URL einfügen", - "prov.pasteCommandCodeHint": "Fügen Sie einen Command-Code-API-Schlüssel (user_… aus ~/.commandcode/auth.json) ein, um ihn als weiteres Konto hinzuzufügen. Alternativ können Sie die Redirect-URL aus dem Browser einfügen.", + "prov.pasteCommandCodePlaceholder": "Command-Code-API-Schlüssel, Autorisierungscode oder Redirect-URL einfügen", + "prov.pasteCommandCodeHint": "Fügen Sie einen Command-Code-API-Schlüssel (user_… aus ~/.commandcode/auth.json), einen Autorisierungscode oder die Redirect-URL aus dem Browser ein, um ihn als weiteres Konto hinzuzufügen.", "prov.pasteSubmit": "Senden", "prov.pasteSubmitting": "Wird gesendet…", "prov.pasteOk": "Code gesendet — Anmeldung wird abgeschlossen…", + "prov.manualErrorEmpty": "Geben Sie eine Redirect-URL, einen Autorisierungscode oder einen API-Schlüssel ein.", + "prov.manualErrorTooLarge": "Eingefügter Inhalt ist zu groß.", + "prov.manualErrorNoLogin": "Kein Login aktiv. Starten Sie „Konto hinzufügen“ erneut.", + "prov.manualErrorStale": "Dieses Einfügen gehört zu einem früheren Login. Starten Sie „Konto hinzufügen“ erneut und fügen Sie die neue URL bzw. den neuen Code ein.", + "prov.manualErrorNoCode": "Kein Autorisierungscode im eingefügten Inhalt gefunden.", + "prov.manualErrorMissingState": "Der Redirect-URL fehlt der State-Parameter. Fügen Sie die vollständige URL dieses Logins ein.", + "prov.manualErrorStateMismatch": "State stimmt nicht überein — fügen Sie die Redirect-URL dieses Logins ein.", + "prov.manualErrorInvalid": "Code konnte nicht gesendet werden. Prüfen Sie den eingefügten Wert und versuchen Sie es erneut.", "prov.pasteFail": "Code konnte nicht gesendet werden: {error}", "prov.port": "Port", "prov.default": "Standard", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7b497d08c5..f166288fd9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -403,11 +403,19 @@ export const en = { "prov.accountId": "ID", "prov.pasteRedirect": "Paste redirect URL or code", "prov.pasteRedirectHint": "If the browser shows a localhost error, copy the full URL from its address bar and paste it here (or paste the authorization code).", - "prov.pasteCommandCodePlaceholder": "Paste Command Code API key or redirect URL", - "prov.pasteCommandCodeHint": "Paste a Command Code API key (user_… from ~/.commandcode/auth.json) to add it as another account, or the redirect URL from the browser.", + "prov.pasteCommandCodePlaceholder": "Paste Command Code API key, authorization code, or redirect URL", + "prov.pasteCommandCodeHint": "Paste a Command Code API key (user_… from ~/.commandcode/auth.json), an authorization code, or the redirect URL from the browser to add it as another account.", "prov.pasteSubmit": "Submit", "prov.pasteSubmitting": "Submitting…", "prov.pasteOk": "Code submitted — finishing login…", + "prov.manualErrorEmpty": "Enter a redirect URL, authorization code, or API key.", + "prov.manualErrorTooLarge": "Pasted content is too large.", + "prov.manualErrorNoLogin": "No login in progress. Start Add account again.", + "prov.manualErrorStale": "This paste belongs to a previous login. Start Add account again and paste the new URL or code.", + "prov.manualErrorNoCode": "No authorization code found in the pasted content.", + "prov.manualErrorMissingState": "Redirect URL is missing the state parameter. Paste the full URL from this login attempt.", + "prov.manualErrorStateMismatch": "State mismatch — paste the redirect URL from this login attempt.", + "prov.manualErrorInvalid": "Could not submit the code. Check the pasted value and try again.", "prov.pasteFail": "Could not submit code: {error}", "prov.port": "Port", "prov.default": "Default", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2686a63585..0eca7ee59a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -390,11 +390,19 @@ export const fr: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "Coller l’URL de redirection ou le code", "prov.pasteRedirectHint": "Si le navigateur affiche une erreur localhost, copiez l’URL complète depuis sa barre d’adresse et collez-la ici (ou collez le code d’autorisation).", - "prov.pasteCommandCodePlaceholder": "Coller la clé API Command Code ou l’URL de redirection", - "prov.pasteCommandCodeHint": "Collez une clé API Command Code (user_… depuis ~/.commandcode/auth.json) pour l’ajouter comme autre compte, ou l’URL de redirection du navigateur.", + "prov.pasteCommandCodePlaceholder": "Coller la clé API Command Code, le code d'autorisation ou l’URL de redirection", + "prov.pasteCommandCodeHint": "Collez une clé API Command Code (user_… depuis ~/.commandcode/auth.json), un code d’autorisation ou l’URL de redirection du navigateur pour l’ajouter comme autre compte.", "prov.pasteSubmit": "Envoyer", "prov.pasteSubmitting": "Envoi…", "prov.pasteOk": "Code envoyé — finalisation de la connexion…", + "prov.manualErrorEmpty": "Saisissez une URL de redirection, un code d'autorisation ou une clé API.", + "prov.manualErrorTooLarge": "Le contenu collé est trop volumineux.", + "prov.manualErrorNoLogin": "Aucune connexion en cours. Relancez Ajouter un compte.", + "prov.manualErrorStale": "Ce collage appartient à une tentative précédente. Relancez Ajouter un compte et collez la nouvelle URL ou le nouveau code.", + "prov.manualErrorNoCode": "Aucun code d'autorisation trouvé dans le contenu collé.", + "prov.manualErrorMissingState": "Le paramètre state manque dans l’URL de redirection. Collez l’URL complète de cette tentative.", + "prov.manualErrorStateMismatch": "État incohérent — collez l’URL de redirection de cette tentative.", + "prov.manualErrorInvalid": "Impossible d’envoyer le code. Vérifiez la valeur collée et réessayez.", "prov.pasteFail": "Impossible d’envoyer le code : {error}", "prov.port": "Port", "prov.default": "Par défaut", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6b702db48f..d3f66cef58 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -386,11 +386,19 @@ export const ja: Record = { "prov.codeCopied": "コードをコピーしました", "prov.pasteRedirect": "リダイレクト URL またはコードを貼り付け", "prov.pasteRedirectHint": "ブラウザに localhost エラーが表示された場合は、アドレスバーの完全な URL をコピーしてここに貼り付けてください(認可コードも可)。", - "prov.pasteCommandCodePlaceholder": "Command Code API キーまたはリダイレクト URL を貼り付け", - "prov.pasteCommandCodeHint": "Command Code API キー(~/.commandcode/auth.json の user_…)を貼り付けると別のアカウントとして追加されます。またはブラウザのリダイレクト URL を貼り付けます。", + "prov.pasteCommandCodePlaceholder": "Command Code API キー、認可コード、またはリダイレクト URL を貼り付け", + "prov.pasteCommandCodeHint": "Command Code API キー(~/.commandcode/auth.json の user_…)、認可コード、またはブラウザのリダイレクト URL を貼り付けると別のアカウントとして追加されます。", "prov.pasteSubmit": "送信", "prov.pasteSubmitting": "送信中…", "prov.pasteOk": "コードを送信しました — ログインを完了しています…", + "prov.manualErrorEmpty": "リダイレクト URL、認可コード、または API キーを入力してください。", + "prov.manualErrorTooLarge": "貼り付けた内容が大きすぎます。", + "prov.manualErrorNoLogin": "ログイン進行中ではありません。Add account からやり直してください。", + "prov.manualErrorStale": "この貼り付けは以前のログインに属します。Add account をやり直して新しい URL またはコードを貼り付けてください。", + "prov.manualErrorNoCode": "貼り付けた内容に認可コードが見つかりません。", + "prov.manualErrorMissingState": "リダイレクト URL に state パラメータがありません。このログインの完全な URL を貼り付けてください。", + "prov.manualErrorStateMismatch": "state が一致しません — このログインの URL を貼り付けてください。", + "prov.manualErrorInvalid": "コードを送信できませんでした。貼り付けた値をご確認のうえ再試行してください。", "prov.pasteFail": "コードを送信できませんでした: {error}", "prov.port": "ポート", "prov.default": "デフォルト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d59e33425a..d324a264d3 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -389,11 +389,19 @@ export const ko: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "리다이렉트 URL 또는 코드 붙여넣기", "prov.pasteRedirectHint": "브라우저에 localhost 오류가 표시되면 주소 표시줄의 전체 URL을 복사하여 여기에 붙여넣으세요(또는 인증 코드를 붙여넣으세요).", - "prov.pasteCommandCodePlaceholder": "Command Code API 키 또는 리다이렉트 URL 붙여넣기", - "prov.pasteCommandCodeHint": "Command Code API 키(~/.commandcode/auth.json의 user_…)를 붙여넣어 다른 계정으로 추가하거나 브라우저의 리다이렉트 URL을 붙여넣으세요.", + "prov.pasteCommandCodePlaceholder": "Command Code API 키, 인증 코드 또는 리다이렉트 URL 붙여넣기", + "prov.pasteCommandCodeHint": "Command Code API 키(~/.commandcode/auth.json의 user_…), 인증 코드 또는 브라우저의 리다이렉트 URL을 붙여넣어 다른 계정으로 추가하세요.", "prov.pasteSubmit": "제출", "prov.pasteSubmitting": "제출 중…", "prov.pasteOk": "코드를 제출했습니다 — 로그인 완료 중…", + "prov.manualErrorEmpty": "리다이렉트 URL, 인증 코드 또는 API 키를 입력하세요.", + "prov.manualErrorTooLarge": "붙여넣은 내용이 너무 큽니다.", + "prov.manualErrorNoLogin": "진행 중인 로그인이 없습니다. Add account를 다시 시작하세요.", + "prov.manualErrorStale": "이 붙여넣기는 이전 로그인에 속합니다. Add account를 다시 시작해 새 URL이나 코드를 붙여넣으세요.", + "prov.manualErrorNoCode": "붙여넣은 내용에서 인증 코드를 찾을 수 없습니다.", + "prov.manualErrorMissingState": "리다이렉트 URL에 state 파라미터가 없습니다. 이번 로그인의 전체 URL을 붙여넣으세요.", + "prov.manualErrorStateMismatch": "state가 일치하지 않습니다 — 이번 로그인의 리다이렉트 URL을 붙여넣으세요.", + "prov.manualErrorInvalid": "코드를 제출할 수 없습니다. 붙여넣은 값을 확인하고 다시 시도하세요.", "prov.pasteFail": "코드 제출 실패: {error}", "prov.port": "포트", "prov.default": "기본값", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8e1cb877de..64efec1837 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -391,11 +391,19 @@ export const ru: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "Вставьте URL перенаправления или код", "prov.pasteRedirectHint": "Если браузер показывает ошибку localhost, скопируйте полный URL из адресной строки и вставьте его сюда (или вставьте код авторизации).", - "prov.pasteCommandCodePlaceholder": "Вставьте API-ключ Command Code или URL перенаправления", - "prov.pasteCommandCodeHint": "Вставьте API-ключ Command Code (user_… из ~/.commandcode/auth.json), чтобы добавить его как ещё один аккаунт, или URL перенаправления из браузера.", + "prov.pasteCommandCodePlaceholder": "Вставьте API-ключ Command Code, код авторизации или URL перенаправления", + "prov.pasteCommandCodeHint": "Вставьте API-ключ Command Code (user_… из ~/.commandcode/auth.json), код авторизации или URL перенаправления из браузера, чтобы добавить его как ещё один аккаунт.", "prov.pasteSubmit": "Отправить", "prov.pasteSubmitting": "Отправка…", "prov.pasteOk": "Код отправлен — завершаем вход…", + "prov.manualErrorEmpty": "Введите URL перенаправления, код авторизации или API-ключ.", + "prov.manualErrorTooLarge": "Вставленное содержимое слишком велико.", + "prov.manualErrorNoLogin": "Вход не выполняется. Снова нажмите «Добавить аккаунт».", + "prov.manualErrorStale": "Эта вставка относится к предыдущей попытке входа. Снова начните «Добавить аккаунт» и вставьте новую ссылку или код.", + "prov.manualErrorNoCode": "Код авторизации не найден во вставленном содержимом.", + "prov.manualErrorMissingState": "В URL перенаправления отсутствует параметр state. Вставьте полный URL этой попытки входа.", + "prov.manualErrorStateMismatch": "Несовпадение state — вставьте URL перенаправления именно этой попытки входа.", + "prov.manualErrorInvalid": "Не удалось отправить код. Проверьте вставленное значение и повторите попытку.", "prov.pasteFail": "Не удалось отправить код: {error}", "prov.port": "Порт", "prov.default": "По умолчанию", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f68d1c104a..cf26a21640 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -394,11 +394,19 @@ export const tr: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "Yönlendirme URL'sini veya kodu yapıştırın", "prov.pasteRedirectHint": "Tarayıcı bir localhost hatası gösteriyorsa, adres çubuğundaki tam URL'yi kopyalayıp buraya yapıştırın (veya yetkilendirme kodunu yapıştırın).", - "prov.pasteCommandCodePlaceholder": "Command Code API anahtarını veya yönlendirme URL'sini yapıştırın", - "prov.pasteCommandCodeHint": "Başka bir hesap olarak eklemek için bir Command Code API anahtarı (user_…, ~/.commandcode/auth.json) veya tarayıcıdan yönlendirme URL'sini yapıştırın.", + "prov.pasteCommandCodePlaceholder": "Command Code API anahtarı, yetkilendirme kodu veya yönlendirme URL’sini yapıştırın", + "prov.pasteCommandCodeHint": "Başka bir hesap olarak eklemek için bir Command Code API anahtarı (user_… — ~/.commandcode/auth.json), bir yetkilendirme kodu veya tarayıcıdaki yönlendirme URL’sini yapıştırın.", "prov.pasteSubmit": "Gönder", "prov.pasteSubmitting": "Gönderiliyor…", "prov.pasteOk": "Kod gönderildi — giriş tamamlanıyor…", + "prov.manualErrorEmpty": "Bir yönlendirme URL’si, yetkilendirme kodu veya API anahtarı girin.", + "prov.manualErrorTooLarge": "Yapıştırılan içerik çok büyük.", + "prov.manualErrorNoLogin": "Giriş işlemi yok. Hesap ekle’yi yeniden başlatın.", + "prov.manualErrorStale": "Bu yapıştırma önceki girişe ait. Hesap ekle’yi yeniden başlatıp yeni URL veya kodu yapıştırın.", + "prov.manualErrorNoCode": "Yapıştırılan içerikte yetkilendirme kodu bulunamadı.", + "prov.manualErrorMissingState": "Yönlendirme URL’sinde state parametresi eksik. Bu girişin tam URL’sini yapıştırın.", + "prov.manualErrorStateMismatch": "State uyuşmuyor — bu girişin yönlendirme URL’sini yapıştırın.", + "prov.manualErrorInvalid": "Kod gönderilemedi. Yapıştırılan değeri kontrol edip tekrar deneyin.", "prov.pasteFail": "Kod gönderilemedi: {error}", "prov.port": "Port", "prov.default": "Varsayılan", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4ffe2d9302..f5c2fc41c1 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -281,11 +281,19 @@ export const zhTW: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "貼上重定向 URL 或授權碼", "prov.pasteRedirectHint": "如果瀏覽器顯示 localhost 錯誤,請複製網址列的完整 URL 並貼到此處(或貼上授權碼)。", - "prov.pasteCommandCodePlaceholder": "貼上 Command Code API 金鑰或重新導向 URL", - "prov.pasteCommandCodeHint": "貼上 Command Code API 金鑰(~/.commandcode/auth.json 中的 user_…)以新增為另一個帳戶,或貼上瀏覽器中的重新導向 URL。", + "prov.pasteCommandCodePlaceholder": "貼上 Command Code API 金鑰、授權碼或重新導向 URL", + "prov.pasteCommandCodeHint": "貼上 Command Code API 金鑰(~/.commandcode/auth.json 中的 user_…)、授權碼或瀏覽器中的重新導向 URL,以新增為另一個帳號。", "prov.pasteSubmit": "提交", "prov.pasteSubmitting": "提交中…", "prov.pasteOk": "已提交授權碼 — 正在完成登入…", + "prov.manualErrorEmpty": "請輸入重新導向 URL、授權碼或 API 金鑰。", + "prov.manualErrorTooLarge": "貼上的內容過大。", + "prov.manualErrorNoLogin": "沒有進行中的登入,請重新開始新增帳號。", + "prov.manualErrorStale": "此貼上屬於上一次登入,請重新開始新增帳號並貼上新的 URL 或授權碼。", + "prov.manualErrorNoCode": "在貼上內容中找不到授權碼。", + "prov.manualErrorMissingState": "重新導向 URL 缺少 state 參數,請貼上本次登入的完整 URL。", + "prov.manualErrorStateMismatch": "state 不符 — 請貼上本次登入的重新導向 URL。", + "prov.manualErrorInvalid": "無法提交手動登入資料,請檢查貼上內容後重試。", "prov.pasteFail": "無法提交授權碼:{error}", "prov.port": "連接埠", "prov.default": "預設", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 47673379e8..def8b8b9de 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -386,11 +386,19 @@ export const zh: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "粘贴重定向 URL 或授权码", "prov.pasteRedirectHint": "如果浏览器显示 localhost 错误,请复制地址栏中的完整 URL 并粘贴到这里(或粘贴授权码)。", - "prov.pasteCommandCodePlaceholder": "粘贴 Command Code API 密钥或重定向 URL", - "prov.pasteCommandCodeHint": "粘贴 Command Code API 密钥(~/.commandcode/auth.json 中的 user_…)以添加为另一个账户,或粘贴浏览器中的重定向 URL。", + "prov.pasteCommandCodePlaceholder": "粘贴 Command Code API 密钥、授权码或重定向 URL", + "prov.pasteCommandCodeHint": "粘贴 Command Code API 密钥(~/.commandcode/auth.json 中的 user_…)、授权码或浏览器中的重定向 URL,以添加为另一个账号。", "prov.pasteSubmit": "提交", "prov.pasteSubmitting": "提交中…", "prov.pasteOk": "已提交代码 — 正在完成登录…", + "prov.manualErrorEmpty": "请输入重定向 URL、授权码或 API 密钥。", + "prov.manualErrorTooLarge": "粘贴内容过大。", + "prov.manualErrorNoLogin": "没有进行中的登录,请重新开始添加账号。", + "prov.manualErrorStale": "此粘贴属于上一次登录,请重新开始添加账号并粘贴新的 URL 或授权码。", + "prov.manualErrorNoCode": "在粘贴内容中未找到授权码。", + "prov.manualErrorMissingState": "重定向 URL 缺少 state 参数,请粘贴本次登录的完整 URL。", + "prov.manualErrorStateMismatch": "state 不匹配 — 请粘贴本次登录的重定向 URL。", + "prov.manualErrorInvalid": "无法提交授权码,请检查粘贴内容后重试。", "prov.pasteFail": "无法提交代码:{error}", "prov.port": "端口", "prov.default": "默认", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index bea7465937..2954ded474 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; import type { WorkspaceProvider } from "../provider-workspace/catalog"; +import type { LoginHint } from "../components/provider-workspace/types"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { oauthTosRisk } from "../oauth-tos-risk"; import { ToastNotice, type NoticeTone } from "../ui"; @@ -36,7 +37,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const [oauthProviders, setOauthProviders] = useState([]); const [oauthStatus, setOauthStatus] = useState>({}); const [busy, setBusy] = useState(null); - const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); + const [loginInfo, setLoginInfo] = useState(null); const [workspaceSelected, setWorkspaceSelected] = useState(null); const [addIntent, setAddIntent] = useState(null); const [removeConfirmName, setRemoveConfirmName] = useState(null); diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 26905c589b..10537b802b 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -4,8 +4,40 @@ import { readJsonIfOk } from "../fetch-json"; import type { OAuthAccount, OAuthStatus } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; +/** + * Tagged error for internally mapped API/validation failures — only these are safe to render. + * Transport/fetch rejections never use this type and are mapped to prov.networkError. + */ +export class SafeManualCodeError extends Error { + readonly safe = true as const; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "SafeManualCodeError"; + } +} +/** Exported for tests: map API error string + status to localized key. */ +export function mapManualCodeApiErrorToKey(raw: string, status: number): string { + const lower = raw.trim().toLowerCase(); + if (lower.includes("empty code")) return "prov.manualErrorEmpty"; + if (lower.includes("code too large") || lower.includes("input too long") || status === 413) return "prov.manualErrorTooLarge"; + if (lower.includes("no login") || lower.includes("no login in progress")) return "prov.manualErrorNoLogin"; + if (lower.includes("stale login")) return "prov.manualErrorStale"; + if (lower.includes("no authorization code")) return "prov.manualErrorNoCode"; + if (lower.includes("missing the state")) return "prov.manualErrorMissingState"; + if (lower.includes("state mismatch")) return "prov.manualErrorStateMismatch"; + if (status >= 500) return "prov.networkError"; + return "prov.manualErrorInvalid"; +} + type AccountSet = { activeAccountId: string | null; accounts: OAuthAccount[] }; +export interface OAuthHook { + cancelLoginOAuth: (provider: string) => Promise; + loginOAuth: (provider: string, addAccount?: boolean, accountId?: string) => Promise; + logoutOAuth: (provider: string) => Promise; + submitManualCode: (provider: string, input: string) => Promise<"submitted" | "cancelled">; +} + export function useProvidersOAuth({ apiBase, t, @@ -31,7 +63,7 @@ export function useProvidersOAuth({ setAccountSets: React.Dispatch>>; setBusy: React.Dispatch>; setStatus: React.Dispatch>; - setLoginInfo: React.Dispatch>; + setLoginInfo: React.Dispatch>; setOauthStatus: React.Dispatch>>; notify: (msg: string, ok: boolean) => void; fetchConfig: () => Promise; @@ -44,10 +76,17 @@ export function useProvidersOAuth({ }) { const oauthLoginGenerationRef = useRef | null>(null); if (oauthLoginGenerationRef.current === null) oauthLoginGenerationRef.current = new Map(); + const oauthAttemptIdRef = useRef | null>(null); + if (oauthAttemptIdRef.current === null) oauthAttemptIdRef.current = new Map(); + const manualAbortRef = useRef | null>(null); + if (manualAbortRef.current === null) manualAbortRef.current = new Map(); const bumpLoginGeneration = useCallback((provider: string) => { const gen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1; oauthLoginGenerationRef.current!.set(provider, gen); + oauthAttemptIdRef.current!.delete(provider); + const ctrl = manualAbortRef.current!.get(provider); + if (ctrl) { ctrl.abort(); manualAbortRef.current!.delete(provider); } return gen; }, []); @@ -90,9 +129,12 @@ export function useProvidersOAuth({ notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false); return; } - const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string }; + const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string; attemptId?: string }; + if (data.attemptId && typeof data.attemptId === "string") { + oauthAttemptIdRef.current!.set(provider, data.attemptId); + } if (data.url || data.instructions || data.deviceCode) { - setLoginInfo({ provider, url: data.url, instructions: data.instructions, deviceCode: data.deviceCode }); + setLoginInfo({ provider, url: data.url, instructions: data.instructions, deviceCode: data.deviceCode, attemptId: data.attemptId }); } const baselineCount = accountSets[provider]?.accounts.length ?? 0; let finished = false; @@ -211,22 +253,37 @@ export function useProvidersOAuth({ } }; - const submitManualCode = async (provider: string, input: string) => { + const submitManualCode = async (provider: string, input: string): Promise<"submitted" | "cancelled"> => { + const attemptId = oauthAttemptIdRef.current!.get(provider); + const controller = new AbortController(); + const prev = manualAbortRef.current!.get(provider); + if (prev) prev.abort(); + manualAbortRef.current!.set(provider, controller); try { const res = await fetch(`${apiBase}/api/oauth/login/code`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, input }), + body: JSON.stringify({ provider, input, attemptId }), + signal: controller.signal, }); - if (!aliveRef.current) return; + if (!aliveRef.current) return "cancelled"; if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; - throw new Error(data.error || res.statusText); + const raw = data.error || ""; + const key = mapManualCodeApiErrorToKey(raw, res.status); + throw new SafeManualCodeError(t(key as never), { cause: new Error(data.error || String(res.status)) } as ErrorOptions); } + return "submitted"; } catch (error) { + if ((error as Error)?.name === "AbortError") return "cancelled"; if (aliveRef.current) { - throw error instanceof Error ? error : new Error(String(error)); + if (error instanceof SafeManualCodeError && error.message) throw error; + // Every other rejection (including fetch errors that happen to carry a cause) is transport. + throw new Error(t("prov.networkError" as never), { cause: error }); } + return "cancelled"; + } finally { + if (manualAbortRef.current!.get(provider) === controller) manualAbortRef.current!.delete(provider); } }; diff --git a/gui/tests/provider-auth-manual-code.test.tsx b/gui/tests/provider-auth-manual-code.test.tsx index 11943a1b6c..4d2af40ccd 100644 --- a/gui/tests/provider-auth-manual-code.test.tsx +++ b/gui/tests/provider-auth-manual-code.test.tsx @@ -6,14 +6,18 @@ import { LanguageProvider } from "../src/i18n/provider"; import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; import type { ProviderAuthHandlers } from "../src/components/provider-workspace/types"; import type { WorkspaceItem } from "../src/provider-workspace/catalog"; +import type { TFn } from "../src/i18n/shared"; +import { SafeManualCodeError, mapManualCodeApiErrorToKey, useProvidersOAuth } from "../src/pages/use-providers-oauth"; +import type { OAuthHook } from "../src/pages/use-providers-oauth"; const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previous: Record<(typeof globals)[number], unknown>; let win: Window; let host: HTMLElement; let root: Root | null = null; -let submit: (provider: string, input: string) => Promise; +let submit: (provider: string, input: string) => Promise<"submitted" | "cancelled">; let rejection: unknown; +let originalFetch: typeof globalThis.fetch; const item: WorkspaceItem = { name: "command-code", @@ -23,12 +27,14 @@ const item: WorkspaceItem = { }; beforeEach(() => { + originalFetch = globalThis.fetch; previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; win = new Window({ url: "http://localhost/" }); Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); rejection = new Error("invalid authorization code"); submit = mock(async () => { if (rejection) throw rejection; + return "submitted" as const; }); Object.defineProperties(globalThis, { document: { configurable: true, value: win.document }, window: { configurable: true, value: win }, @@ -42,6 +48,7 @@ beforeEach(() => { afterEach(async () => { if (root) { const current = root; await act(async () => { current.unmount(); }); root = null; } for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: originalFetch }); await win.happyDOM?.close?.(); }); @@ -143,8 +150,8 @@ test("masks pasted Command Code credentials and preserves rejection feedback", a }); test("identifies Command Code manual auth from the provider contract", async () => { - const { createRoot } = await import("react-dom/client"); const aliasedItem: WorkspaceItem = { ...item, name: "command-code-work" }; + const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( @@ -170,7 +177,6 @@ test("identifies Command Code manual auth from the provider contract", async () }); test("ignores completion from a stale manual-auth flow", async () => { - const { createRoot } = await import("react-dom/client"); let resolveSubmit!: () => void; const deferredSubmit = mock(() => new Promise(resolve => { resolveSubmit = resolve; })); const handlers: ProviderAuthHandlers = { @@ -179,6 +185,7 @@ test("ignores completion from a stale manual-auth flow", async () => { onSubmitManualCode: deferredSubmit, }; + const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); root.render( @@ -225,3 +232,318 @@ test("ignores completion from a stale manual-auth flow", async () => { expect(host.querySelector('[role="alert"]')).toBeNull(); expect((host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).disabled).toBe(true); }); +test("instructions-only change resets manual-auth input and feedback", async () => { + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submit, + }; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + const input = host.querySelector('input[type="password"]') as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "user_secret_prev"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); + await new Promise(r => setTimeout(r, 0)); + }); + expect(host.querySelector('[role="alert"]')).not.toBeNull(); + // Same URL/deviceCode, but instructions differ -> flow key must rotate and clear input + feedback. + await act(async () => { + root!.render( + + + , + ); + }); + expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(""); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect(host.querySelector('[role="status"]')).toBeNull(); +}); + +test("attemptId rotation resets manual-auth input and ignores stale completion", async () => { + let resolveSubmit!: () => void; + const deferredSubmit = mock(() => new Promise(r => { resolveSubmit = r; })); + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: deferredSubmit, + }; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + const input = host.querySelector('input[type="password"]') as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "user_secret_a"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); + }); + expect(deferredSubmit).toHaveBeenCalledTimes(1); + await act(async () => { + root!.render( + + + , + ); + }); + expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(""); + await act(async () => { + resolveSubmit(); + await new Promise(r => setTimeout(r, 0)); + }); + expect(host.querySelector('[role="status"]')).toBeNull(); + expect(host.querySelector('[role="alert"]')).toBeNull(); +}); +test("cancelled manual submit does not render success on same flow", async () => { + const submitCancelled = mock(async () => "cancelled" as const); + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submitCancelled, + }; + + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + + const input = host.querySelector('input[type="password"]') as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "user_secret_cancelled"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); + await new Promise(r => setTimeout(r, 0)); + }); + // Hook returned "cancelled" (e.g. AbortError): panel must not show success nor busy lock. + expect(host.querySelector('[role="status"]')).toBeNull(); + expect(host.querySelector('[role="alert"]')).toBeNull(); + expect((host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).disabled).toBe(false); + // Ensure a plain undefined/void outcome also does not trigger success (legacy compat: panel treats non-cancelled as success only when submitted). + // Covered by earlier stale-completion test. +}); +test("undefined/non-submitted outcome does not render success", async () => { + const submitUndefined = async () => undefined as unknown as "submitted" | "cancelled"; + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submitUndefined as unknown as ProviderAuthHandlers["onSubmitManualCode"], + }; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + const input = host.querySelector('input[type="password"]') as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "user_secret"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); + await new Promise(r => setTimeout(r, 0)); + }); + expect(host.querySelector('[role="status"]')).toBeNull(); + expect(host.querySelector('[role="alert"]')).toBeNull(); +}); +test("cause-carrying fetch rejection is mapped to networkError — literal never reaches UI", async () => { + // Simulate what useProvidersOAuth now does: a fetch rejection with cause must NOT leak its message. + // The real hook would catch `new Error("ETIMEDOUT 1.2.3.4:443", { cause: new Error("ECONNREFUSED") })` + // and rethrow as `prov.networkError` with the original as `cause`. The panel only sees the localized message. + // We simulate the post-hook throw: it must be the localized network error, never the ETIMEDOUT literal. + const rawFetchError = new Error("ETIMEDOUT 1.2.3.4:443", { cause: new Error("ECONNREFUSED inner") }); + // What the fixed hook throws for this raw error: + const mapped = new Error("Network error. Check that the proxy is running and try again.", { cause: rawFetchError }); + const submitFromHook = mock(async () => { + throw mapped; + }); + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submitFromHook as unknown as ProviderAuthHandlers["onSubmitManualCode"], + }; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + const input = host.querySelector('input[type="password"]') as HTMLInputElement; + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(input, "user_secret"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + (host.querySelector(".pwi-auth-paste button") as HTMLButtonElement).click(); + await new Promise(r => setTimeout(r, 0)); + }); + const alert = host.querySelector('[role="alert"]')?.textContent ?? ""; + expect(alert).toContain("Network error"); + expect(alert).not.toContain("ETIMEDOUT"); + expect(alert).not.toContain("1.2.3.4"); + expect(alert).not.toContain("ECONNREFUSED"); +}); +function HookHarness({ apiBase, onReady }: { apiBase: string; onReady: (api: OAuthHook) => void }) { + const aliveRef = { current: true } as React.MutableRefObject; + const t = ((key: string) => key) as unknown as TFn; + const api = useProvidersOAuth({ + apiBase, + t, + aliveRef, + accountSets: {}, + setAccountSets: () => {}, + setBusy: () => {}, + setStatus: () => {}, + setLoginInfo: () => {}, + setOauthStatus: () => {}, + notify: () => {}, + fetchConfig: async () => {}, + fetchOauth: async () => {}, + fetchAccountSets: async () => {}, + fetchProviderQuotas: async () => {}, + bumpModelsRefresh: () => {}, + }); + onReady(api); + return null; +} + +test("mapManualCodeApiErrorToKey covers all branches", () => { + expect(mapManualCodeApiErrorToKey("empty code", 400)).toBe("prov.manualErrorEmpty"); + expect(mapManualCodeApiErrorToKey("code too large", 400)).toBe("prov.manualErrorTooLarge"); + expect(mapManualCodeApiErrorToKey("input too long", 400)).toBe("prov.manualErrorTooLarge"); + expect(mapManualCodeApiErrorToKey("", 413)).toBe("prov.manualErrorTooLarge"); + expect(mapManualCodeApiErrorToKey("no login in progress", 409)).toBe("prov.manualErrorNoLogin"); + expect(mapManualCodeApiErrorToKey("stale login attempt", 409)).toBe("prov.manualErrorStale"); + expect(mapManualCodeApiErrorToKey("no authorization code found in input", 409)).toBe("prov.manualErrorNoCode"); + expect(mapManualCodeApiErrorToKey("redirect URL is missing the state parameter", 409)).toBe( + "prov.manualErrorMissingState", + ); + expect(mapManualCodeApiErrorToKey("state mismatch \u2014 paste the redirect URL from THIS login attempt", 409)).toBe( + "prov.manualErrorStateMismatch", + ); + expect(mapManualCodeApiErrorToKey("unknown diagnostic", 500)).toBe("prov.networkError"); + expect(mapManualCodeApiErrorToKey("unknown diagnostic", 400)).toBe("prov.manualErrorInvalid"); +}); + +test("hook: cause-carrying transport via real submitManualCode maps to networkError only", async () => { + let hook: OAuthHook | null = null; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: "stale login attempt" }), { + status: 409, + headers: { "content-type": "application/json" }, + })) as unknown as typeof fetch; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + { hook = api; }} /> + , + ); + }); + expect(hook).not.toBeNull(); + let threw: unknown = null; + try { + await hook!.submitManualCode("xai", "badcode"); + } catch (e) { + threw = e; + } + expect(threw).toBeInstanceOf(SafeManualCodeError); + expect((threw as Error).message).toBe("prov.manualErrorStale"); + await act(async () => { + root!.unmount(); + }); + root = null; + + const raw = new Error("ETIMEDOUT 1.2.3.4:443", { cause: new Error("ECONNREFUSED inner") }); + globalThis.fetch = (async () => { + throw raw; + }) as unknown as typeof fetch; + hook = null; + await act(async () => { + root = createRoot(host); + root.render( + + { hook = api; }} /> + , + ); + }); + threw = null; + try { + await hook!.submitManualCode("xai", "anything"); + } catch (e) { + threw = e; + } + expect(threw).not.toBeInstanceOf(SafeManualCodeError); + expect(threw instanceof Error && (threw as Error).message).toBe("prov.networkError"); + expect((threw as Error & { cause?: unknown }).cause).toBe(raw); + expect((threw as Error).message).not.toContain("ETIMEDOUT"); + expect((threw as Error).message).not.toContain("1.2.3.4"); +}); diff --git a/src/oauth/index.ts b/src/oauth/index.ts index fe3abe7656..566215503d 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1254,7 +1254,8 @@ export async function runLogin( * localhost), the GUI can POST the final redirect URL or authorization code via * submitManualLoginCode(), which feeds OAuthController.onManualCodeInput. */ -const loginState = new Map(); +const loginState = new Map(); +const loginAttemptId = new Map(); const loginAbort = new Map(); const kiroLoginSettling = new Set(); @@ -1277,6 +1278,7 @@ export function reconcileOAuthFlowState(context: GenerationContext): number { if (loginState.delete(provider)) removed += 1; if (loginManual.delete(provider)) removed += 1; if (loginAbort.delete(provider)) removed += 1; + if (loginAttemptId.delete(provider)) removed += 1; } lastOAuthFlowReconciledGeneration = context.generation; return removed; @@ -1326,12 +1328,17 @@ function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedS * Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted * here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks. */ -export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } { +export function submitManualLoginCode(provider: string, input: string, attemptId?: string): { ok: true } | { ok: false; error: string } { const trimmed = input.trim(); if (!trimmed) return { ok: false, error: "empty code" }; if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" }; const st = loginState.get(provider); if (!st || st.done) return { ok: false, error: "no login in progress" }; + // An active login always carries an attempt ID (startLoginFlow). Fail closed if it does not. + const activeAttemptId = loginAttemptId.get(provider); + if (!activeAttemptId || !attemptId || attemptId !== activeAttemptId) { + return { ok: false, error: "stale login attempt" }; + } const slot = ensureManualCodeSlot(provider); // Synchronous validation (validated request/ack): reject un-parseable input and // authorization responses (url/query kind) whose state is missing or mismatched @@ -1403,6 +1410,7 @@ export function clearLoginState(provider: string): void { loginAbort.get(provider)?.abort("cleared"); loginAbort.delete(provider); clearManualCodeSlot(provider); + loginAttemptId.delete(provider); loginState.delete(provider); } @@ -1413,6 +1421,7 @@ export function cancelLoginFlow(provider: string): boolean { ctrl?.abort("cancelled"); loginAbort.delete(provider); clearManualCodeSlot(provider); + loginAttemptId.delete(provider); loginState.set(provider, { done: true, error: "Login cancelled" }); return true; } @@ -1421,7 +1430,7 @@ export async function startLoginFlow( provider: string, opts?: LoginOpts, lifecycle?: LoginFlowLifecycle, -): Promise<{ url: string; instructions?: string; deviceCode?: string }> { +): Promise<{ url: string; instructions?: string; deviceCode?: string; attemptId: string }> { const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); const existing = loginState.get(provider); @@ -1429,7 +1438,9 @@ export async function startLoginFlow( throw new Error(`A login for ${provider} is already in progress`); } clearManualCodeSlot(provider); - loginState.set(provider, { done: false }); + const attemptId = randomUUID(); + loginAttemptId.set(provider, attemptId); + loginState.set(provider, { done: false, attemptId }); const abort = new AbortController(); loginAbort.set(provider, abort); if (provider === "kiro") kiroLoginSettling.add(provider); @@ -1438,7 +1449,7 @@ export async function startLoginFlow( const ctrl: OAuthController = { onAuth: ({ url, instructions, deviceCode }) => { urlResolved = true; - resolve({ url, instructions, deviceCode }); + resolve({ url, instructions, deviceCode, attemptId }); }, onProgress: () => {}, // GUI fallback when the browser cannot hit the loopback callback server. @@ -1467,16 +1478,18 @@ export async function startLoginFlow( if (finalError === undefined) { loginAbort.delete(provider); clearManualCodeSlot(provider); + loginAttemptId.delete(provider); loginState.set(provider, { done: true }); // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth — // resolve so the GUI call returns instead of hanging. - if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." }); + if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed.", attemptId }); return; } const e = finalError; loginAbort.delete(provider); clearManualCodeSlot(provider); + loginAttemptId.delete(provider); const msg = publicOAuthAuthenticationErrorMessage(e); loginState.set(provider, { done: true, error: msg }); if (!urlResolved) reject(e); @@ -1494,6 +1507,7 @@ export async function startLoginFlow( if (abandonIfNotOwner(e)) return; loginAbort.delete(provider); clearManualCodeSlot(provider); + loginAttemptId.delete(provider); const msg = publicOAuthAuthenticationErrorMessage(e); loginState.set(provider, { done: true, error: msg }); if (!urlResolved) reject(e); diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index d9a20e37f2..d9f581b9d4 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -155,7 +155,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // request may already have mutated live config and yielded before its save. const persistedBaseline = readConfigDiagnostics().config; // addAccount / reauth forces a fresh browser identity (skips local-CLI token import). - const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, { + const { url: authUrl, instructions, deviceCode, attemptId } = await startLoginFlow(provider, { forceLogin: body.addAccount === true || reauth, ...(accountId ? { reauthAccountId: accountId } : {}), }, { @@ -173,7 +173,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { openUrl } = await import("../../lib/open-url"); openUrl(authUrl); } - return jsonResponse({ url: authUrl, instructions, deviceCode }); + return jsonResponse({ url: authUrl, instructions, deviceCode, attemptId }); } catch (err) { if (err instanceof OAuthMutationBusyError) throw err; const message = err instanceof Error ? err.message : String(err); @@ -200,14 +200,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // Manual fallback for browser OAuth: paste the final redirect URL (or authorization code) // when the browser cannot reach the loopback callback (remote/SSH/blocked localhost). if (url.pathname === "/api/oauth/login/code" && req.method === "POST") { - const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; input?: string; code?: string }; + const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; input?: string; code?: string; attemptId?: string }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : ""; + const attemptId = typeof body.attemptId === "string" ? body.attemptId.trim() || undefined : undefined; // Authorization responses are measured in hundreds of bytes; never accept the // generic management-body allowance here. if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400); - const result = submitManualLoginCode(provider, input); + const result = submitManualLoginCode(provider, input, attemptId); if (!result.ok) return jsonResponse({ error: result.error }, 409); return jsonResponse({ ok: true }); } diff --git a/tests/oauth-manual-code.test.ts b/tests/oauth-manual-code.test.ts index 346d1c7a8c..6700934371 100644 --- a/tests/oauth-manual-code.test.ts +++ b/tests/oauth-manual-code.test.ts @@ -91,8 +91,8 @@ describe("OAuth manual login code fallback", () => { return originalFetch(input, init); }) as typeof fetch; try { - await startLoginFlow("xai", { forceLogin: true }); - expect(submitManualLoginCode("xai", `${"한".repeat(1365)}xx`)).toEqual({ + const startedTooLarge = await startLoginFlow("xai", { forceLogin: true }); + expect(submitManualLoginCode("xai", `${"한".repeat(1365)}xx`, startedTooLarge.attemptId)).toEqual({ ok: false, error: "code too large", }); @@ -143,21 +143,21 @@ describe("OAuth manual login code fallback", () => { // Wait until the flow registers its expected state with the manual-code slot: // a mismatched redirect URL must then be rejected SYNCHRONOUSLY. const deadline = Date.now() + 5_000; - let mismatch = submitManualLoginCode("xai", `${redirectUri}?code=evil&state=WRONG`); + let mismatch = submitManualLoginCode("xai", `${redirectUri}?code=evil&state=WRONG`, started.attemptId); while (mismatch.ok && Date.now() < deadline) { await new Promise(r => setTimeout(r, 50)); - mismatch = submitManualLoginCode("xai", `${redirectUri}?code=evil&state=WRONG`); + mismatch = submitManualLoginCode("xai", `${redirectUri}?code=evil&state=WRONG`, started.attemptId); } expect(mismatch.ok).toBe(false); if (!mismatch.ok) expect(mismatch.error).toBe("state mismatch — paste the redirect URL from THIS login attempt"); // URL-shaped input with NO state is rejected, not downgraded to a raw code. - const missingState = submitManualLoginCode("xai", `${redirectUri}?code=abc`); + const missingState = submitManualLoginCode("xai", `${redirectUri}?code=abc`, started.attemptId); expect(missingState.ok).toBe(false); if (!missingState.ok) expect(missingState.error).toBe("redirect URL is missing the state parameter"); // Correct paste: matching state completes the login via the original verifier. - const goodSubmit = submitManualLoginCode("xai", `${redirectUri}?code=pasted-auth-code&state=${state}`); + const goodSubmit = submitManualLoginCode("xai", `${redirectUri}?code=pasted-auth-code&state=${state}`, started.attemptId); expect(goodSubmit).toEqual({ ok: true }); // Background runLogin finishes: poll status until done. @@ -213,8 +213,8 @@ describe("OAuth manual login code fallback", () => { return originalFetch(input, init); }) as typeof fetch; try { - await startLoginFlow("xai", { forceLogin: true }); - const raw = submitManualLoginCode("xai", "manual-auth-code-only"); + const startedRaw = await startLoginFlow("xai", { forceLogin: true }); + const raw = submitManualLoginCode("xai", "manual-auth-code-only", startedRaw.attemptId); expect(raw).toEqual({ ok: true }); const statusDeadline = Date.now() + 10_000; while (!getLoginStatus("xai").done && Date.now() < statusDeadline) { @@ -253,8 +253,93 @@ describe("OAuth manual login code fallback", () => { const noLogin = await post({ provider: "xai", input: "some-code" }); expect(noLogin.status).toBe(409); expect(((await noLogin.json()) as { error?: string }).error).toBe("no login in progress"); + // New: stale attemptId cannot reach replacement flow. + { + const loginRes = await fetch(new URL("/api/oauth/login", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "xai", addAccount: true }), + }); + expect(loginRes.status).toBe(200); + const loginData = (await loginRes.json()) as { attemptId?: string; url?: string }; + const attemptA = loginData.attemptId; + expect(typeof attemptA).toBe("string"); + // Delayed A: would be consumed if server keyed only by provider. + // Cancel A and start B; A must be stale. + const cancelRes = await fetch(new URL("/api/oauth/login/cancel", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + expect(cancelRes.status).toBe(200); + const loginB = await fetch(new URL("/api/oauth/login", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "xai", addAccount: true }), + }); + expect(loginB.status).toBe(200); + const dataB = (await loginB.json()) as { attemptId?: string }; + const attemptB = dataB.attemptId!; + expect(attemptB).not.toBe(attemptA); + const stale = await post({ provider: "xai", input: "manual-auth-delayed", attemptId: attemptA }); + expect(stale.status).toBe(409); + expect(((await stale.json()) as { error?: string }).error).toBe("stale login attempt"); + // Correct attempt still accepts input. + const fresh = await post({ provider: "xai", input: "manual-auth-delayed", attemptId: attemptB }); + // fresh may be ok or rejected for no code/bad code, but must NOT be stale + if (fresh.status === 409) { + const body = (await fresh.json()) as { error?: string }; + expect(body.error).not.toBe("stale login attempt"); + } + await fetch(new URL("/api/oauth/login/cancel", server.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "xai" }), + }); + } } finally { await server.stop(true); } }); + + test("stale raw user_ credential cannot reach replacement flow", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("openid-configuration")) { + return new Response(JSON.stringify({ + authorization_endpoint: "https://auth.x.ai/authorize", + token_endpoint: "https://auth.x.ai/oauth/token", + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return originalFetch(input, init); + }) as typeof fetch; + try { + const first = await startLoginFlow("xai", { forceLogin: true }); + const attemptA = first.attemptId; + expect(typeof attemptA).toBe("string"); + // Raw paste succeeds for the active attempt. + expect(submitManualLoginCode("xai", "manual-raw-a", attemptA)).toEqual({ ok: true }); + // Cancel and start a replacement flow: delayed raw for A must be stale. + cancelLoginFlow("xai"); + clearLoginState("xai"); + const second = await startLoginFlow("xai", { forceLogin: true }); + const attemptB = second.attemptId; + expect(attemptB).not.toBe(attemptA); + const stale = submitManualLoginCode("xai", "manual-raw-a", attemptA); + expect(stale.ok).toBe(false); + if (!stale.ok) expect(stale.error).toBe("stale login attempt"); + // Correct attempt can still paste. + expect(submitManualLoginCode("xai", "manual-raw-b", attemptB)).toEqual({ ok: true }); + // Missing attemptId is also stale when an attempt is active. + const missing = submitManualLoginCode("xai", "manual-raw-b"); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.error).toBe("stale login attempt"); + } finally { + globalThis.fetch = originalFetch; + cancelLoginFlow("xai"); + clearLoginState("xai"); + } + }); + });