refactor: dedupe OAuth flow handlers
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
|||||||
applyGoogleGeminiModelDefault,
|
applyGoogleGeminiModelDefault,
|
||||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||||
} from "./google-gemini-model-default.js";
|
} from "./google-gemini-model-default.js";
|
||||||
|
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
|
||||||
import {
|
import {
|
||||||
applyAuthProfileConfig,
|
applyAuthProfileConfig,
|
||||||
applyMinimaxApiConfig,
|
applyMinimaxApiConfig,
|
||||||
@@ -97,61 +98,8 @@ function normalizeApiKeyInput(raw: string): string {
|
|||||||
return withoutSemicolon.trim();
|
return withoutSemicolon.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateApiKeyInput = (value: unknown) => {
|
const validateApiKeyInput = (value: string) =>
|
||||||
const normalized =
|
normalizeApiKeyInput(value).length > 0 ? undefined : "Required";
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatApiKeyPreview(
|
function formatApiKeyPreview(
|
||||||
raw: string,
|
raw: string,
|
||||||
@@ -628,6 +576,7 @@ export async function applyAuthChoice(params: {
|
|||||||
prompter: params.prompter,
|
prompter: params.prompter,
|
||||||
runtime: params.runtime,
|
runtime: params.runtime,
|
||||||
spin,
|
spin,
|
||||||
|
openUrl,
|
||||||
localBrowserMessage: "Complete sign-in in browser…",
|
localBrowserMessage: "Complete sign-in in browser…",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -690,6 +639,7 @@ export async function applyAuthChoice(params: {
|
|||||||
prompter: params.prompter,
|
prompter: params.prompter,
|
||||||
runtime: params.runtime,
|
runtime: params.runtime,
|
||||||
spin,
|
spin,
|
||||||
|
openUrl,
|
||||||
localBrowserMessage: "Complete sign-in in browser…",
|
localBrowserMessage: "Complete sign-in in browser…",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -109,4 +109,75 @@ describe("loginChutes", () => {
|
|||||||
expect(creds.refresh).toBe("rt_manual");
|
expect(creds.refresh).toBe("rt_manual");
|
||||||
expect(creds.email).toBe("manual-user");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,13 +125,19 @@ export async function loginChutes(params: {
|
|||||||
app: ChutesOAuthAppConfig;
|
app: ChutesOAuthAppConfig;
|
||||||
manual?: boolean;
|
manual?: boolean;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
createPkce?: typeof generateChutesPkce;
|
||||||
|
createState?: () => string;
|
||||||
onAuth: (event: { url: string }) => Promise<void>;
|
onAuth: (event: { url: string }) => Promise<void>;
|
||||||
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
||||||
onProgress?: (message: string) => void;
|
onProgress?: (message: string) => void;
|
||||||
fetchFn?: typeof fetch;
|
fetchFn?: typeof fetch;
|
||||||
}): Promise<OAuthCredentials> {
|
}): Promise<OAuthCredentials> {
|
||||||
const { verifier, challenge } = generateChutesPkce();
|
const createPkce = params.createPkce ?? generateChutesPkce;
|
||||||
const state = randomBytes(16).toString("hex");
|
const createState =
|
||||||
|
params.createState ?? (() => randomBytes(16).toString("hex"));
|
||||||
|
|
||||||
|
const { verifier, challenge } = createPkce();
|
||||||
|
const state = createState();
|
||||||
const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000;
|
const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000;
|
||||||
|
|
||||||
const url = buildAuthorizeUrl({
|
const url = buildAuthorizeUrl({
|
||||||
|
|||||||
56
src/commands/oauth-flow.ts
Normal file
56
src/commands/oauth-flow.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user