From f566e6451f7edb1595d643f87fbfa1e1b9e66e90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 05:01:08 +0000 Subject: [PATCH] fix: harden Chutes OAuth flow (#726) (thanks @FrieSei) --- CHANGELOG.md | 1 + src/agents/auth-profiles.ts | 34 +++--- src/agents/chutes-oauth.ts | 10 +- src/agents/pi-embedded-helpers.test.ts | 4 +- src/commands/auth-choice.ts | 151 ++++++++++++------------- src/commands/chutes-oauth.ts | 6 +- src/commands/onboard-auth.ts | 4 +- 7 files changed, 101 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f2f17b3..1b5142a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan) - Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm) +- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei) - Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated. ### Fixes diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 8a7524844..6306132c9 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -15,7 +15,7 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; -import { type ChutesStoredOAuth, refreshChutesTokens } from "./chutes-oauth.js"; +import { refreshChutesTokens } from "./chutes-oauth.js"; import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, @@ -68,7 +68,8 @@ export type TokenCredential = { export type OAuthCredential = OAuthCredentials & { type: "oauth"; - provider: OAuthProvider; + provider: string; + clientId?: string; email?: string; }; @@ -172,7 +173,7 @@ async function updateAuthProfileStoreWithLock(params: { } function buildOAuthApiKey( - provider: OAuthProvider, + provider: string, credentials: OAuthCredentials, ): string { const needsProjectId = @@ -187,7 +188,6 @@ function buildOAuthApiKey( async function refreshOAuthTokenWithLock(params: { profileId: string; - provider: OAuthProvider; agentDir?: string; }): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { const authPath = resolveAuthStorePath(params.agentDir); @@ -218,11 +218,11 @@ async function refreshOAuthTokenWithLock(params: { String(cred.provider) === "chutes" ? await (async () => { const newCredentials = await refreshChutesTokens({ - credential: cred as unknown as ChutesStoredOAuth, + credential: cred, }); return { apiKey: newCredentials.access, newCredentials }; })() - : await getOAuthApiKey(cred.provider, oauthCreds); + : await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds); if (!result) return null; store.profiles[params.profileId] = { ...cred, @@ -269,7 +269,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { } entries[key] = { ...typed, - provider: typed.provider ?? (key as OAuthProvider), + provider: String(typed.provider ?? key), } as AuthProfileCredential; } return Object.keys(entries).length > 0 ? entries : null; @@ -336,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { if (store.profiles[profileId]) continue; store.profiles[profileId] = { type: "oauth", - provider: provider as OAuthProvider, + provider, ...creds, }; mutated = true; @@ -478,7 +478,7 @@ function syncExternalCliCredentials( const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID]; const shouldSyncCodex = !existingCodex || - existingCodex.provider !== ("openai-codex" as OAuthProvider) || + existingCodex.provider !== "openai-codex" || !isExternalProfileFresh(existingCodex, now); const codexCreds = shouldSyncCodex ? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) @@ -490,7 +490,7 @@ function syncExternalCliCredentials( // Codex creds don't carry expiry; use file mtime heuristic for freshness. const shouldUpdate = !existingOAuth || - existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) || + existingOAuth.provider !== "openai-codex" || existingOAuth.expires <= now || codexCreds.expires > existingOAuth.expires; @@ -535,14 +535,14 @@ export function loadAuthProfileStore(): AuthProfileStore { if (cred.type === "api_key") { store.profiles[profileId] = { type: "api_key", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; } else if (cred.type === "token") { store.profiles[profileId] = { type: "token", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), token: cred.token, ...(typeof cred.expires === "number" ? { expires: cred.expires } @@ -552,7 +552,7 @@ export function loadAuthProfileStore(): AuthProfileStore { } else { store.profiles[profileId] = { type: "oauth", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), access: cred.access, refresh: cred.refresh, expires: cred.expires, @@ -600,14 +600,14 @@ export function ensureAuthProfileStore( if (cred.type === "api_key") { store.profiles[profileId] = { type: "api_key", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; } else if (cred.type === "token") { store.profiles[profileId] = { type: "token", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), token: cred.token, ...(typeof cred.expires === "number" ? { expires: cred.expires } @@ -617,7 +617,7 @@ export function ensureAuthProfileStore( } else { store.profiles[profileId] = { type: "oauth", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), access: cred.access, refresh: cred.refresh, expires: cred.expires, @@ -1231,7 +1231,6 @@ export async function resolveApiKeyForProfile(params: { try { const result = await refreshOAuthTokenWithLock({ profileId, - provider: cred.provider, agentDir: params.agentDir, }); if (!result) return null; @@ -1351,7 +1350,6 @@ async function tryResolveOAuthProfile(params: { const refreshed = await refreshOAuthTokenWithLock({ profileId, - provider: cred.provider, agentDir: params.agentDir, }); if (!refreshed) return null; diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index bf31b3808..b10a6669b 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -26,7 +26,6 @@ export type ChutesOAuthAppConfig = { export type ChutesStoredOAuth = OAuthCredentials & { clientId?: string; - clientSecret?: string; }; export function generateChutesPkce(): ChutesPkce { @@ -45,7 +44,7 @@ export function parseOAuthCallbackInput( try { const url = new URL(trimmed); const code = url.searchParams.get("code"); - const state = url.searchParams.get("state") ?? expectedState; + const state = url.searchParams.get("state"); if (!code) return { error: "Missing 'code' parameter in URL" }; if (!state) { return { error: "Missing 'state' parameter. Paste the full URL." }; @@ -138,7 +137,6 @@ export async function exchangeChutesCodeForTokens(params: { email: info?.username, accountId: info?.sub, clientId: params.app.clientId, - clientSecret: params.app.clientSecret, } as unknown as ChutesStoredOAuth; } @@ -162,10 +160,7 @@ export async function refreshChutesTokens(params: { "Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).", ); } - const clientSecret = - params.credential.clientSecret?.trim() ?? - process.env.CHUTES_CLIENT_SECRET?.trim() ?? - undefined; + const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; const body = new URLSearchParams({ grant_type: "refresh_token", @@ -201,6 +196,5 @@ export async function refreshChutesTokens(params: { refresh: newRefresh || refreshToken, expires: coerceExpiresAt(expiresIn, now), clientId, - clientSecret, } as unknown as ChutesStoredOAuth; } diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index 40d350ae0..bdf2a7db0 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -68,9 +68,7 @@ describe("buildBootstrapContextFiles", () => { ); expect(result?.content.length).toBeLessThan(long.length); expect(result?.content.startsWith(long.slice(0, 120))).toBe(true); - expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe( - true, - ); + expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("TOOLS.md"); expect(warnings[0]).toContain("limit 200"); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index bc54dd038..56df52234 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -1,8 +1,4 @@ -import { - loginOpenAICodex, - type OAuthCredentials, - type OAuthProvider, -} from "@mariozechner/pi-ai"; +import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { CLAUDE_CLI_PROFILE_ID, @@ -52,7 +48,6 @@ import { applySyntheticConfig, applySyntheticProviderConfig, applyZaiConfig, - MINIMAX_HOSTED_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF, @@ -105,6 +100,56 @@ function normalizeApiKeyInput(raw: string): string { const validateApiKeyInput = (value: unknown) => normalizeApiKeyInput(String(value ?? "")).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); + }, + }; +} + function formatApiKeyPreview( raw: string, opts: { head?: number; tail?: number } = {}, @@ -574,43 +619,25 @@ export async function applyAuthChoice(params: { ); const spin = params.prompter.progress("Starting OAuth flow…"); - let manualCodePromise: Promise | undefined; try { + const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ + isRemote, + prompter: params.prompter, + runtime: params.runtime, + spin, + localBrowserMessage: "Complete sign-in in browser…", + }); + const creds = await loginChutes({ app: { clientId, clientSecret, redirectUri, - scopes: scopes.split(/\\s+/).filter(Boolean), + scopes: scopes.split(/\s+/).filter(Boolean), }, manual: isRemote, - onAuth: async ({ url }) => { - if (isRemote) { - 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: (value) => (value?.trim() ? undefined : "Required"), - }) - .then((value) => String(value)); - } else { - spin.update("Complete sign-in in browser…"); - 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: (value) => (value?.trim() ? undefined : "Required"), - }); - return String(code); - }, + onAuth, + onPrompt, onProgress: (msg) => spin.update(msg), }); @@ -618,11 +645,7 @@ export async function applyAuthChoice(params: { const email = creds.email?.trim() || "default"; const profileId = `chutes:${email}`; - await writeOAuthCredentials( - "chutes" as unknown as OAuthProvider, - creds, - params.agentDir, - ); + await writeOAuthCredentials("chutes", creds, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId, provider: "chutes", @@ -637,7 +660,7 @@ export async function applyAuthChoice(params: { "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", `Verify the OAuth app redirect URI includes: ${redirectUri}`, "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", - ].join("\\n"), + ].join("\n"), "OAuth help", ); } @@ -658,47 +681,23 @@ export async function applyAuthChoice(params: { "OpenAI Codex OAuth", ); const spin = params.prompter.progress("Starting OAuth flow…"); - let manualCodePromise: Promise | undefined; try { + const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ + isRemote, + prompter: params.prompter, + runtime: params.runtime, + spin, + localBrowserMessage: "Complete sign-in in browser…", + }); + const creds = await loginOpenAICodex({ - onAuth: async ({ url }) => { - if (isRemote) { - 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: (value) => (value?.trim() ? undefined : "Required"), - }) - .then((value) => String(value)); - } else { - spin.update("Complete sign-in in browser…"); - 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: (value) => (value?.trim() ? undefined : "Required"), - }); - return String(code); - }, + onAuth, + onPrompt, onProgress: (msg) => spin.update(msg), }); spin.stop("OpenAI OAuth complete"); if (creds) { - await writeOAuthCredentials( - "openai-codex" as unknown as OAuthProvider, - creds, - params.agentDir, - ); + await writeOAuthCredentials("openai-codex", creds, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "openai-codex:default", provider: "openai-codex", diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index 526bbb654..8dcea1532 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -1,9 +1,11 @@ +import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; import { + CHUTES_AUTHORIZE_ENDPOINT, exchangeChutesCodeForTokens, generateChutesPkce, parseOAuthCallbackInput, @@ -30,7 +32,7 @@ function buildAuthorizeUrl(params: { code_challenge: params.challenge, code_challenge_method: "S256", }); - return `https://api.chutes.ai/idp/authorize?${qs.toString()}`; + return `${CHUTES_AUTHORIZE_ENDPOINT}?${qs.toString()}`; } async function waitForLocalCallback(params: { @@ -129,7 +131,7 @@ export async function loginChutes(params: { fetchFn?: typeof fetch; }): Promise { const { verifier, challenge } = generateChutesPkce(); - const state = verifier; + const state = randomBytes(16).toString("hex"); const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000; const url = buildAuthorizeUrl({ diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 0e593d988..39664fec4 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,4 +1,4 @@ -import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; +import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; @@ -103,7 +103,7 @@ function buildMoonshotModelDefinition(): ModelDefinitionConfig { } export async function writeOAuthCredentials( - provider: OAuthProvider, + provider: string, creds: OAuthCredentials, agentDir?: string, ): Promise {