diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index e5d47b8c3..4318c9cfb 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -33,6 +33,7 @@ import { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL, } from "./google-gemini-model-default.js"; +import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; import { applyAuthProfileConfig, applyMinimaxApiConfig, @@ -97,61 +98,8 @@ function normalizeApiKeyInput(raw: string): string { return withoutSemicolon.trim(); } -const validateApiKeyInput = (value: unknown) => { - const normalized = - typeof value === "string" ? normalizeApiKeyInput(value) : ""; - return normalized.length > 0 ? undefined : "Required"; -}; - -const validateRequiredInput = (value: string) => - value.trim().length > 0 ? undefined : "Required"; - -function createVpsAwareOAuthHandlers(params: { - isRemote: boolean; - prompter: WizardPrompter; - runtime: RuntimeEnv; - spin: ReturnType; - localBrowserMessage: string; -}): { - onAuth: (event: { url: string }) => Promise; - onPrompt: (prompt: { - message: string; - placeholder?: string; - }) => Promise; -} { - let manualCodePromise: Promise | undefined; - - return { - onAuth: async ({ url }) => { - if (params.isRemote) { - params.spin.stop("OAuth URL ready"); - params.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, - ); - manualCodePromise = params.prompter - .text({ - message: "Paste the redirect URL (or authorization code)", - validate: validateRequiredInput, - }) - .then((value) => String(value)); - return; - } - - params.spin.update(params.localBrowserMessage); - await openUrl(url); - params.runtime.log(`Open: ${url}`); - }, - onPrompt: async (prompt) => { - if (manualCodePromise) return manualCodePromise; - const code = await params.prompter.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: validateRequiredInput, - }); - return String(code); - }, - }; -} +const validateApiKeyInput = (value: string) => + normalizeApiKeyInput(value).length > 0 ? undefined : "Required"; function formatApiKeyPreview( raw: string, @@ -628,6 +576,7 @@ export async function applyAuthChoice(params: { prompter: params.prompter, runtime: params.runtime, spin, + openUrl, localBrowserMessage: "Complete sign-in in browser…", }); @@ -690,6 +639,7 @@ export async function applyAuthChoice(params: { prompter: params.prompter, runtime: params.runtime, spin, + openUrl, localBrowserMessage: "Complete sign-in in browser…", }); diff --git a/src/commands/chutes-oauth.test.ts b/src/commands/chutes-oauth.test.ts index ff1108894..924ce18c6 100644 --- a/src/commands/chutes-oauth.test.ts +++ b/src/commands/chutes-oauth.test.ts @@ -109,4 +109,75 @@ describe("loginChutes", () => { expect(creds.refresh).toBe("rt_manual"); expect(creds.email).toBe("manual-user"); }); + + it("does not reuse code_verifier as state", async () => { + const fetchFn: typeof fetch = async (input) => { + const url = String(input); + if (url === CHUTES_TOKEN_ENDPOINT) { + return new Response( + JSON.stringify({ + access_token: "at_manual", + refresh_token: "rt_manual", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === CHUTES_USERINFO_ENDPOINT) { + return new Response(JSON.stringify({ username: "manual-user" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }; + + const createPkce = () => ({ + verifier: "verifier_123", + challenge: "chal_123", + }); + const createState = () => "state_456"; + + const creds = await loginChutes({ + app: { + clientId: "cid_test", + redirectUri: "http://127.0.0.1:1456/oauth-callback", + scopes: ["openid"], + }, + manual: true, + createPkce, + createState, + onAuth: async ({ url }) => { + const parsed = new URL(url); + expect(parsed.searchParams.get("state")).toBe("state_456"); + expect(parsed.searchParams.get("state")).not.toBe("verifier_123"); + }, + onPrompt: async () => "code_manual", + fetchFn, + }); + + expect(creds.access).toBe("at_manual"); + }); + + it("rejects pasted redirect URLs missing state", async () => { + const fetchFn: typeof fetch = async () => + new Response("not found", { status: 404 }); + + await expect( + loginChutes({ + app: { + clientId: "cid_test", + redirectUri: "http://127.0.0.1:1456/oauth-callback", + scopes: ["openid"], + }, + manual: true, + createPkce: () => ({ verifier: "verifier_123", challenge: "chal_123" }), + createState: () => "state_456", + onAuth: async () => {}, + onPrompt: async () => + "http://127.0.0.1:1456/oauth-callback?code=code_only", + fetchFn, + }), + ).rejects.toThrow("Missing 'state' parameter"); + }); }); diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index 8dcea1532..002718812 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -125,13 +125,19 @@ export async function loginChutes(params: { app: ChutesOAuthAppConfig; manual?: boolean; timeoutMs?: number; + createPkce?: typeof generateChutesPkce; + createState?: () => string; onAuth: (event: { url: string }) => Promise; onPrompt: (prompt: OAuthPrompt) => Promise; onProgress?: (message: string) => void; fetchFn?: typeof fetch; }): Promise { - const { verifier, challenge } = generateChutesPkce(); - const state = randomBytes(16).toString("hex"); + const createPkce = params.createPkce ?? generateChutesPkce; + const createState = + params.createState ?? (() => randomBytes(16).toString("hex")); + + const { verifier, challenge } = createPkce(); + const state = createState(); const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000; const url = buildAuthorizeUrl({ diff --git a/src/commands/oauth-flow.ts b/src/commands/oauth-flow.ts new file mode 100644 index 000000000..e18af1ba9 --- /dev/null +++ b/src/commands/oauth-flow.ts @@ -0,0 +1,56 @@ +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +type OAuthPrompt = { message: string; placeholder?: string }; + +const validateRequiredInput = (value: string) => + value.trim().length > 0 ? undefined : "Required"; + +export function createVpsAwareOAuthHandlers(params: { + isRemote: boolean; + prompter: WizardPrompter; + runtime: RuntimeEnv; + spin: ReturnType; + openUrl: (url: string) => Promise; + localBrowserMessage: string; + manualPromptMessage?: string; +}): { + onAuth: (event: { url: string }) => Promise; + onPrompt: (prompt: OAuthPrompt) => Promise; +} { + const manualPromptMessage = + params.manualPromptMessage ?? + "Paste the redirect URL (or authorization code)"; + let manualCodePromise: Promise | undefined; + + return { + onAuth: async ({ url }) => { + if (params.isRemote) { + params.spin.stop("OAuth URL ready"); + params.runtime.log( + `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, + ); + manualCodePromise = params.prompter + .text({ + message: manualPromptMessage, + validate: validateRequiredInput, + }) + .then((value) => String(value)); + return; + } + + params.spin.update(params.localBrowserMessage); + await params.openUrl(url); + params.runtime.log(`Open: ${url}`); + }, + onPrompt: async (prompt) => { + if (manualCodePromise) return manualCodePromise; + const code = await params.prompter.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: validateRequiredInput, + }); + return String(code); + }, + }; +}