refactor: dedupe OAuth flow handlers

This commit is contained in:
Peter Steinberger
2026-01-13 05:13:01 +00:00
parent d8f14078f0
commit 01776e0569
4 changed files with 140 additions and 57 deletions

View File

@@ -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<WizardPrompter["progress"]>;
localBrowserMessage: string;
}): {
onAuth: (event: { url: string }) => Promise<void>;
onPrompt: (prompt: {
message: string;
placeholder?: string;
}) => Promise<string>;
} {
let manualCodePromise: Promise<string> | 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…",
});

View File

@@ -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");
});
});

View File

@@ -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<void>;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
fetchFn?: typeof fetch;
}): Promise<OAuthCredentials> {
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({

View File

@@ -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<WizardPrompter["progress"]>;
openUrl: (url: string) => Promise<unknown>;
localBrowserMessage: string;
manualPromptMessage?: string;
}): {
onAuth: (event: { url: string }) => Promise<void>;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
} {
const manualPromptMessage =
params.manualPromptMessage ??
"Paste the redirect URL (or authorization code)";
let manualCodePromise: Promise<string> | 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);
},
};
}