From fce7a1590f6986a901d5ff82240be53ae85cecaf Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Thu, 16 Apr 2026 19:31:51 +0800 Subject: [PATCH] fix(openai-oauth): surface actionable OAuth failures in settings --- src/components/settings/ProviderManager.tsx | 31 +++++- src/lib/openai-oauth-manager.ts | 101 +++++++++++++------- src/lib/openai-oauth.ts | 52 +++++++--- 3 files changed, 135 insertions(+), 49 deletions(-) diff --git a/src/components/settings/ProviderManager.tsx b/src/components/settings/ProviderManager.tsx index aadfca41..d4c97663 100644 --- a/src/components/settings/ProviderManager.tsx +++ b/src/components/settings/ProviderManager.tsx @@ -52,6 +52,20 @@ export function ProviderManager() { const [envDetected, setEnvDetected] = useState>({}); const { t } = useTranslation(); const isZh = t('nav.chats') === '对话'; + const mapOpenAIOAuthError = useCallback((raw: string): string => { + const lower = raw.toLowerCase(); + if (lower.includes('country, region, or territory not supported')) { + return isZh + ? 'OpenAI OAuth 当前在你所在地区不可用。可改用兼容 OpenAI 协议的第三方服务(在 Provider 中配置 Base URL + API Key)。' + : 'OpenAI OAuth is unavailable in your current country/region. You can use an OpenAI-compatible third-party endpoint via Provider settings (Base URL + API key).'; + } + if (lower.includes('invalid or expired state')) { + return isZh + ? '登录会话已过期,请重新点击 OpenAI 登录并在新打开的页面中完成授权。' + : 'The login session has expired. Please click OpenAI Login again and complete authorization in the newly opened page.'; + } + return raw; + }, [isZh]); // Edit dialog state — fallback ProviderForm for providers that don't match any preset const [formOpen, setFormOpen] = useState(false); @@ -67,7 +81,7 @@ export function ProviderManager() { const [deleting, setDeleting] = useState(false); // OpenAI OAuth state - const [openaiAuth, setOpenaiAuth] = useState<{ authenticated: boolean; email?: string; plan?: string } | null>(null); + const [openaiAuth, setOpenaiAuth] = useState<{ authenticated: boolean; email?: string; plan?: string; oauthError?: string } | null>(null); const [openaiLoggingIn, setOpenaiLoggingIn] = useState(false); const [openaiError, setOpenaiError] = useState(null); @@ -100,9 +114,15 @@ export function ProviderManager() { useEffect(() => { fetch('/api/openai-oauth/status') .then(r => r.ok ? r.json() : null) - .then(data => { if (data) setOpenaiAuth(data); }) + .then(data => { + if (!data) return; + setOpenaiAuth(data); + if (!data.authenticated && data.oauthError) { + setOpenaiError(mapOpenAIOAuthError(data.oauthError)); + } + }) .catch(() => {}); - }, []); + }, [mapOpenAIOAuthError]); // Fetch all provider models for the global default model selector const fetchModels = useCallback(() => { @@ -264,6 +284,11 @@ export function ProviderManager() { // counts; broadcast so listeners (SetupCenter's ProviderCard, // anywhere reading provider presence) re-evaluate. window.dispatchEvent(new Event('provider-changed')); + } else if (status.oauthError) { + clearInterval(poll); + setOpenaiAuth(status); + setOpenaiLoggingIn(false); + setOpenaiError(mapOpenAIOAuthError(status.oauthError)); } } } catch { /* keep polling */ } diff --git a/src/lib/openai-oauth-manager.ts b/src/lib/openai-oauth-manager.ts index 07b13c68..f6a0a9db 100644 --- a/src/lib/openai-oauth-manager.ts +++ b/src/lib/openai-oauth-manager.ts @@ -27,6 +27,7 @@ const KEYS = { email: 'openai_oauth_email', plan: 'openai_oauth_plan', accountId: 'openai_oauth_account_id', + lastError: 'openai_oauth_last_error', } as const; const REFRESH_BUFFER_MS = 5 * 60 * 1000; @@ -40,11 +41,17 @@ export interface OpenAIOAuthStatus { accountId?: string; /** True when token is near/past expiry but a refresh token exists */ needsRefresh?: boolean; + oauthError?: string; } export function getOAuthStatus(): OpenAIOAuthStatus { const accessToken = getSetting(KEYS.accessToken); - if (!accessToken) return { authenticated: false }; + if (!accessToken) { + return { + authenticated: false, + oauthError: getSetting(KEYS.lastError) || undefined, + }; + } // Check if token is expired and no refresh token available const expiresAt = Number(getSetting(KEYS.expiresAt) || '0'); @@ -65,6 +72,7 @@ export function getOAuthStatus(): OpenAIOAuthStatus { plan: getSetting(KEYS.plan), accountId: getSetting(KEYS.accountId), needsRefresh, + oauthError: undefined, }; } @@ -132,6 +140,7 @@ function saveTokens(tokens: OAuthTokens): void { setSetting(KEYS.idToken, tokens.idToken); if (tokens.refreshToken) setSetting(KEYS.refreshToken, tokens.refreshToken); if (tokens.expiresAt) setSetting(KEYS.expiresAt, String(tokens.expiresAt)); + setSetting(KEYS.lastError, ''); const claims = parseIdTokenClaims(tokens.idToken); if (claims.email) setSetting(KEYS.email, claims.email); @@ -154,11 +163,12 @@ export function clearOAuthTokens(): void { * Safe to call even when no flow is pending. */ export async function cancelOAuthFlow(): Promise { - const pending = getPendingOAuth(); - if (pending) { + const pendingEntries = Object.values(getPendingOAuthMap()); + for (const pending of pendingEntries) { + clearTimeout(pending.timeout); pending.reject(new Error('OAuth flow cancelled by user')); - setPendingOAuth(undefined); } + oauthState.pendingOAuthByState = {}; await stopOAuthServer(); } @@ -169,13 +179,14 @@ interface PendingOAuth { state: string; resolve: (accessToken: string) => void; reject: (err: Error) => void; + timeout: ReturnType; } // Use globalThis to survive Next.js HMR / module re-evaluation in dev mode. // Without this, hot reload would orphan the callback server and lose pending state. interface OAuthGlobalState { oauthServer?: Server; - pendingOAuth?: PendingOAuth; + pendingOAuthByState?: Record; } const GLOBAL_KEY = '__codepilot_openai_oauth__' as const; const g = globalThis as unknown as Record; @@ -185,39 +196,53 @@ const oauthState = g[GLOBAL_KEY]; // Convenience accessors — all reads/writes go through oauthState function getOAuthServer(): Server | undefined { return oauthState.oauthServer; } function setOAuthServer(s: Server | undefined) { oauthState.oauthServer = s; } -function getPendingOAuth(): PendingOAuth | undefined { return oauthState.pendingOAuth; } -function setPendingOAuth(p: PendingOAuth | undefined) { oauthState.pendingOAuth = p; } +function getPendingOAuthMap(): Record { + if (!oauthState.pendingOAuthByState) oauthState.pendingOAuthByState = {}; + return oauthState.pendingOAuthByState; +} +function getPendingOAuthCount(): number { + return Object.keys(getPendingOAuthMap()).length; +} +function getPendingOAuthByState(state: string): PendingOAuth | undefined { + return getPendingOAuthMap()[state]; +} +function setPendingOAuth(state: string, pending: PendingOAuth): void { + getPendingOAuthMap()[state] = pending; +} +function clearPendingOAuth(state: string): PendingOAuth | undefined { + const pendingMap = getPendingOAuthMap(); + const pending = pendingMap[state]; + if (!pending) return undefined; + delete pendingMap[state]; + return pending; +} /** * Start the OAuth flow: prepare PKCE, start callback server, return auth URL. * MUST be awaited — the server needs to be listening before opening the browser. */ export async function startOAuthFlow(): Promise<{ authUrl: string; completion: Promise }> { - // Clean up any stale state from previous attempts - await stopOAuthServer(); - const prevPending = getPendingOAuth(); - if (prevPending) { - prevPending.reject(new Error('Superseded by new login attempt')); - setPendingOAuth(undefined); - } - const flow = prepareOAuthFlow(); + setSetting(KEYS.lastError, ''); // Start callback server and WAIT for it to be listening await startOAuthServer(); const completion = new Promise((resolve, reject) => { const timeout = setTimeout(() => { - if (getPendingOAuth()) { - setPendingOAuth(undefined); + const pending = clearPendingOAuth(flow.state); + if (pending) { reject(new Error('OAuth callback timeout')); - stopOAuthServer(); + if (getPendingOAuthCount() === 0) { + stopOAuthServer(); + } } }, 5 * 60 * 1000); - setPendingOAuth({ + setPendingOAuth(flow.state, { codeVerifier: flow.codeVerifier, state: flow.state, + timeout, resolve: (token) => { clearTimeout(timeout); resolve(token); }, reject: (err) => { clearTimeout(timeout); reject(err); }, }); @@ -247,43 +272,51 @@ async function startOAuthServer(): Promise { if (error) { const msg = errorDesc || error; + setSetting(KEYS.lastError, msg); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(errorHtml(msg)); - getPendingOAuth()?.reject(new Error(msg)); - setPendingOAuth(undefined); - stopOAuthServer(); + if (state) { + const pending = clearPendingOAuth(state); + pending?.reject(new Error(msg)); + } + if (getPendingOAuthCount() === 0) { + stopOAuthServer(); + } return; } - const pending = getPendingOAuth(); - if (!code || !pending || state !== pending.state) { - const msg = !code ? 'Missing authorization code' : 'Invalid state'; + const pending = state ? getPendingOAuthByState(state) : undefined; + if (!code || !state || !pending) { + const msg = !code ? 'Missing authorization code' : 'Invalid or expired state'; + setSetting(KEYS.lastError, msg); res.writeHead(400, { 'Content-Type': 'text/html' }); res.end(errorHtml(msg)); - getPendingOAuth()?.reject(new Error(msg)); - setPendingOAuth(undefined); - stopOAuthServer(); + if (getPendingOAuthCount() === 0) { + stopOAuthServer(); + } return; } - const current = pending; - setPendingOAuth(undefined); + clearPendingOAuth(state); // Exchange code for tokens FIRST, then show result to user try { - const tokens = await exchangeCodeForTokens(code, current.codeVerifier); + const tokens = await exchangeCodeForTokens(code, pending.codeVerifier); saveTokens(tokens); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(successHtml()); - current.resolve(tokens.accessToken); + pending.resolve(tokens.accessToken); } catch (err) { const message = err instanceof Error ? err.message : String(err); + setSetting(KEYS.lastError, `Token exchange failed: ${message}`); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(errorHtml(`Token exchange failed: ${message}`)); - current.reject(err instanceof Error ? err : new Error(message)); + pending.reject(err instanceof Error ? err : new Error(message)); } - stopOAuthServer(); + if (getPendingOAuthCount() === 0) { + stopOAuthServer(); + } }); setOAuthServer(server); diff --git a/src/lib/openai-oauth.ts b/src/lib/openai-oauth.ts index 3ae62ae1..d691a2e2 100644 --- a/src/lib/openai-oauth.ts +++ b/src/lib/openai-oauth.ts @@ -115,6 +115,44 @@ async function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } +function stringifyUnknown(value: unknown): string { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return ''; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function extractOAuthErrorMessage(rawBody: string, fallbackErr?: unknown): string { + if (!rawBody) { + return fallbackErr instanceof Error ? fallbackErr.message : 'unknown'; + } + try { + const parsed = JSON.parse(rawBody) as Record; + const candidates: unknown[] = [ + parsed.error_description, + typeof parsed.error === 'object' && parsed.error !== null + ? (parsed.error as Record).message + : undefined, + typeof parsed.error === 'object' && parsed.error !== null + ? (parsed.error as Record).code + : undefined, + parsed.error, + parsed.message, + parsed, + ]; + for (const item of candidates) { + const text = stringifyUnknown(item).trim(); + if (text) return text; + } + return rawBody; + } catch { + return rawBody; + } +} + export async function exchangeCodeForTokens( code: string, codeVerifier: string, @@ -190,13 +228,7 @@ export async function exchangeCodeForTokens( // Out of retries — produce a useful error. JSON.stringify the body when // possible so users (and Sentry) see structured fields instead of the // legacy "[object Object]" placeholder that issue #464 complained about. - let msg: string; - try { - const j = JSON.parse(lastBody); - msg = j.error_description || j.error || JSON.stringify(j); - } catch { - msg = lastBody || (lastErr instanceof Error ? lastErr.message : 'unknown'); - } + const msg = extractOAuthErrorMessage(lastBody, lastErr); throw new Error(`Token exchange failed after ${MAX_ATTEMPTS} attempts: ${lastStatus ?? 'network'} - ${msg}`); } @@ -220,11 +252,7 @@ export async function refreshTokens(refreshToken: string): Promise if (!response.ok) { const text = await response.text(); - let msg: string; - try { - const j = JSON.parse(text); - msg = j.error_description || j.error || text; - } catch { msg = text; } + const msg = extractOAuthErrorMessage(text); throw new Error(`Token refresh failed: ${response.status} - ${msg}`); }