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
31 changes: 28 additions & 3 deletions src/components/settings/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ 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 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);
Expand All @@ -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<string | null>(null);

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 */ }
Expand Down
101 changes: 67 additions & 34 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 All @@ -154,11 +163,12 @@ export function clearOAuthTokens(): void {
* Safe to call even when no flow is pending.
*/
export async function cancelOAuthFlow(): Promise<void> {
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();
}

Expand All @@ -169,13 +179,14 @@ interface PendingOAuth {
state: string;
resolve: (accessToken: string) => void;
reject: (err: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}

// 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<string, PendingOAuth>;
}
const GLOBAL_KEY = '__codepilot_openai_oauth__' as const;
const g = globalThis as unknown as Record<string, OAuthGlobalState>;
Expand All @@ -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<string, PendingOAuth> {
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<string> }> {
// 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<string>((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); },
});
Expand Down Expand Up @@ -247,43 +272,51 @@ 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));
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);
Expand Down
52 changes: 40 additions & 12 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);
}
}

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

Expand All @@ -220,11 +252,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