Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/__tests__/unit/openai-oauth-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Tests for nested OAuth error extraction (issue #464).
*
* OpenAI sometimes returns `{ "error": { "message": "..." } }`. The old
* shallow parse used `j.error` as a truthy object, so users saw
* `403 - [object Object]`. These cases pin the nested extract order.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { extractOAuthErrorMessage } from '../../lib/openai-oauth';

describe('extractOAuthErrorMessage', () => {
it('extracts nested error.message and never returns [object Object]', () => {
const raw = JSON.stringify({
error: { message: 'Country, region, or territory not supported' },
});
const msg = extractOAuthErrorMessage(raw);
assert.equal(msg, 'Country, region, or territory not supported');
assert.equal(msg.includes('[object Object]'), false);
});

it('prefers error_description over nested error.message', () => {
const raw = JSON.stringify({
error_description: 'invalid_grant: code expired',
error: { message: 'Country, region, or territory not supported' },
});
assert.equal(extractOAuthErrorMessage(raw), 'invalid_grant: code expired');
});

it('returns a non-JSON body as-is', () => {
const raw = 'upstream 502 bad gateway';
assert.equal(extractOAuthErrorMessage(raw), raw);
});

it('falls back to Error.message when the body is empty', () => {
assert.equal(
extractOAuthErrorMessage('', new Error('socket hang up')),
'socket hang up',
);
});

it('stringifies a string error field', () => {
const raw = JSON.stringify({ error: 'invalid_grant' });
assert.equal(extractOAuthErrorMessage(raw), 'invalid_grant');
});
});

describe('openai-oauth-manager lastError pin', () => {
it('persists openai_oauth_last_error and exposes oauthError', () => {
const src = readFileSync(
join(__dirname, '../../lib/openai-oauth-manager.ts'),
'utf8',
);
assert.match(src, /openai_oauth_last_error/);
assert.match(src, /oauthError/);
});
});
34 changes: 30 additions & 4 deletions src/components/settings/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ export function ProviderManager() {
const [envDetected, setEnvDetected] = useState<Record<string, string>>({});
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 t('provider.openaiOAuth.error.regionUnsupported');
}
if (lower.includes('invalid or expired state') || lower.includes('invalid state')) {
return t('provider.openaiOAuth.error.expiredState');
}
return raw;
}, [t]);

// Edit dialog state — fallback ProviderForm for providers that don't match any preset
const [formOpen, setFormOpen] = useState(false);
Expand All @@ -146,7 +156,12 @@ 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<string | null>(null);

Expand Down Expand Up @@ -369,9 +384,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]);

const fetchXaiOAuthStatus = useCallback(async () => {
try {
Expand Down Expand Up @@ -613,7 +634,7 @@ export function ProviderManager() {
if (pollCount >= maxPolls) {
clearInterval(poll);
setOpenaiLoggingIn(false);
setOpenaiError(isZh ? '登录超时,请重试' : 'Login timed out, please try again');
setOpenaiError(t('provider.openaiOAuth.error.timeout'));
return;
}
try {
Expand All @@ -629,6 +650,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 */ }
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@ const en = {
'provider.ccSwitchHint': 'Claude Code settings have been moved to the Claude Code settings page.',
'provider.goToClaudeCodeSettings': 'Go to Settings',
'provider.openaiOAuthHint': 'Sign in with ChatGPT Plus/Pro to access OpenAI models without an API key.',
'provider.openaiOAuth.error.regionUnsupported':
'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).',
'provider.openaiOAuth.error.expiredState':
'The login session has expired. Please click OpenAI Login again and complete authorization in the newly opened page.',
'provider.openaiOAuth.error.timeout':
'Login timed out, please try again',
'provider.addProviderSection': 'Add Provider',
'provider.addProviderDesc': 'Select a provider to connect. Most presets only require an API key.',
'provider.connectedServices': 'Connected services',
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ const zh: Record<TranslationKey, string> = {
'provider.ccSwitchHint': 'Claude Code 相关设置已迁移到 Claude Code 设置页面。',
'provider.goToClaudeCodeSettings': '前往设置',
'provider.openaiOAuthHint': '使用 ChatGPT Plus/Pro 订阅登录,无需 API Key 即可使用 OpenAI 模型。',
'provider.openaiOAuth.error.regionUnsupported':
'OpenAI OAuth 当前在你所在地区不可用。可改用兼容 OpenAI 协议的第三方服务(在 Provider 中配置 Base URL + API Key)。',
'provider.openaiOAuth.error.expiredState':
'登录会话已过期,请重新点击 OpenAI 登录并在新打开的页面中完成授权。',
'provider.openaiOAuth.error.timeout':
'登录超时,请重试',
'provider.addProviderSection': '添加提供商',
'provider.addProviderDesc': '选择要连接的提供商。大多数预设只需填写 API 密钥。',
'provider.connectedServices': '已连接服务',
Expand Down
17 changes: 15 additions & 2 deletions src/lib/openai-oauth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -65,6 +72,7 @@ export function getOAuthStatus(): OpenAIOAuthStatus {
plan: getSetting(KEYS.plan),
accountId: getSetting(KEYS.accountId),
needsRefresh,
oauthError: undefined,
};
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -200,6 +209,7 @@ export async function startOAuthFlow(): Promise<{ authUrl: string; completion: P
prevPending.reject(new Error('Superseded by new login attempt'));
setPendingOAuth(undefined);
}
setSetting(KEYS.lastError, '');

const flow = prepareOAuthFlow();

Expand Down Expand Up @@ -247,6 +257,7 @@ async function startOAuthServer(): Promise<void> {

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));
Expand All @@ -257,7 +268,8 @@ async function startOAuthServer(): Promise<void> {

const pending = getPendingOAuth();
if (!code || !pending || state !== pending.state) {
const msg = !code ? 'Missing authorization code' : 'Invalid state';
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));
Expand All @@ -278,6 +290,7 @@ async function startOAuthServer(): Promise<void> {
current.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));
Expand Down
57 changes: 42 additions & 15 deletions src/lib/openai-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,44 @@ async function sleep(ms: number): Promise<void> {
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);
}
}

export function extractOAuthErrorMessage(rawBody: string, fallbackErr?: unknown): string {
if (!rawBody) {
return fallbackErr instanceof Error ? fallbackErr.message : 'unknown';
}
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
const candidates: unknown[] = [
parsed.error_description,
typeof parsed.error === 'object' && parsed.error !== null
? (parsed.error as Record<string, unknown>).message
: undefined,
typeof parsed.error === 'object' && parsed.error !== null
? (parsed.error as Record<string, unknown>).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,
Expand Down Expand Up @@ -187,16 +225,9 @@ export async function exchangeCodeForTokens(
break;
}

// 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');
}
// Out of retries — produce a useful error. Nested `{error:{message}}`
// bodies used to interpolate as "[object Object]" (issue #464).
const msg = extractOAuthErrorMessage(lastBody, lastErr);
throw new Error(`Token exchange failed after ${MAX_ATTEMPTS} attempts: ${lastStatus ?? 'network'} - ${msg}`);
}

Expand All @@ -220,11 +251,7 @@ export async function refreshTokens(refreshToken: string): Promise<OAuthTokens>

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}`);
}

Expand Down
Loading