Skip to content

Commit 25f9af5

Browse files
committed
Get chatgpt oauth working
1 parent 07b6845 commit 25f9af5

File tree

8 files changed

+702
-43
lines changed

8 files changed

+702
-43
lines changed

cli/src/components/chatgpt-connect-banner.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { Button } from './button'
55
import { useTheme } from '../hooks/use-theme'
66
import { useChatStore } from '../state/chat-store'
77
import {
8+
connectChatGptOAuth,
89
disconnectChatGptOAuth,
910
exchangeChatGptCodeForTokens,
1011
getChatGptOAuthStatus,
11-
openChatGptOAuthInBrowser,
12+
stopChatGptOAuthServer,
1213
} from '../utils/chatgpt-oauth'
1314

1415
type FlowState =
@@ -32,20 +33,30 @@ export const ChatGptConnectBanner = () => {
3233
}
3334

3435
setFlowState('waiting-for-code')
35-
openChatGptOAuthInBrowser().catch((err) => {
36-
setError(err instanceof Error ? err.message : 'Failed to open browser')
37-
setFlowState('error')
38-
})
36+
connectChatGptOAuth()
37+
.then(() => {
38+
setFlowState('connected')
39+
})
40+
.catch((err) => {
41+
setError(err instanceof Error ? err.message : 'Failed to connect')
42+
setFlowState('error')
43+
})
44+
45+
return () => {
46+
stopChatGptOAuthServer()
47+
}
3948
}, [])
4049

4150
const handleConnect = async () => {
42-
try {
43-
setFlowState('waiting-for-code')
44-
await openChatGptOAuthInBrowser()
45-
} catch (err) {
46-
setError(err instanceof Error ? err.message : 'Failed to open browser')
47-
setFlowState('error')
48-
}
51+
setFlowState('waiting-for-code')
52+
connectChatGptOAuth()
53+
.then(() => {
54+
setFlowState('connected')
55+
})
56+
.catch((err) => {
57+
setError(err instanceof Error ? err.message : 'Failed to connect')
58+
setFlowState('error')
59+
})
4960
}
5061

5162
const handleDisconnect = () => {
@@ -96,7 +107,8 @@ export const ChatGptConnectBanner = () => {
96107
<box style={{ flexDirection: 'column', gap: 0 }}>
97108
<text style={{ fg: theme.info }}>Waiting for ChatGPT authorization</text>
98109
<text style={{ fg: theme.muted, marginTop: 1 }}>
99-
Complete sign-in in your browser, then paste the auth code or callback URL here.
110+
Complete sign-in in your browser — it should connect automatically.
111+
If not, paste the callback URL here.
100112
</text>
101113
</box>
102114
</BottomBanner>
@@ -121,6 +133,7 @@ export async function handleChatGptAuthCode(code: string): Promise<{
121133
}> {
122134
try {
123135
await exchangeChatGptCodeForTokens(code)
136+
stopChatGptOAuthServer()
124137
return {
125138
success: true,
126139
message:

cli/src/utils/chatgpt-oauth.ts

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import crypto from 'crypto'
7+
import http from 'http'
78

89
import {
910
CHATGPT_OAUTH_AUTHORIZE_URL,
@@ -95,14 +96,136 @@ export function startChatGptOAuthFlow(): { codeVerifier: string; authUrl: string
9596
authUrl.searchParams.set('code_challenge_method', 'S256')
9697
authUrl.searchParams.set('state', state)
9798
authUrl.searchParams.set('scope', 'openid profile email offline_access')
99+
authUrl.searchParams.set('id_token_add_organizations', 'true')
100+
authUrl.searchParams.set('codex_cli_simplified_flow', 'true')
101+
authUrl.searchParams.set('originator', 'codex_cli_rs')
98102

99103
return { codeVerifier, authUrl: authUrl.toString() }
100104
}
101105

102-
export async function openChatGptOAuthInBrowser(): Promise<string> {
103-
const { authUrl, codeVerifier } = startChatGptOAuthFlow()
104-
await open(authUrl)
105-
return codeVerifier
106+
const CALLBACK_SERVER_TIMEOUT_MS = 5 * 60 * 1000
107+
108+
let callbackServer: http.Server | null = null
109+
110+
export function stopChatGptOAuthServer(): void {
111+
if (callbackServer) {
112+
try { callbackServer.close() } catch { /* ignore */ }
113+
callbackServer = null
114+
}
115+
pendingCodeVerifier = null
116+
pendingState = null
117+
}
118+
119+
function escapeHtml(s: string): string {
120+
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
121+
}
122+
123+
function callbackPageHtml(success: boolean, errorMessage?: string): string {
124+
const title = success ? 'Connected — Codebuff' : 'Connection Failed — Codebuff'
125+
const heading = success ? '✓ Connected to ChatGPT' : 'Connection Failed'
126+
const headingColor = success ? '#4ade80' : '#f87171'
127+
const body = success
128+
? 'You can close this tab and return to Codebuff.'
129+
: `${escapeHtml(errorMessage ?? 'Unknown error')}. Return to Codebuff and try /connect:chatgpt again.`
130+
return `<!DOCTYPE html>
131+
<html><head><meta charset="utf-8"><title>${title}</title></head>
132+
<body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0a0a0a;color:#e5e5e5">
133+
<div style="text-align:center;padding:2rem">
134+
<h1 style="color:${headingColor};margin-bottom:0.5rem">${heading}</h1>
135+
<p style="color:#a3a3a3">${body}</p>
136+
</div></body></html>`
137+
}
138+
139+
function startCallbackServer(codeVerifier: string): Promise<ChatGptOAuthCredentials> {
140+
const redirectUrl = new URL(CHATGPT_OAUTH_REDIRECT_URI)
141+
const port = parseInt(redirectUrl.port, 10)
142+
const callbackPath = redirectUrl.pathname
143+
144+
return new Promise<ChatGptOAuthCredentials>((resolve, reject) => {
145+
const timeout = setTimeout(() => {
146+
stopChatGptOAuthServer()
147+
reject(new Error('Timeout waiting for ChatGPT authorization'))
148+
}, CALLBACK_SERVER_TIMEOUT_MS)
149+
150+
const server = http.createServer(async (req, res) => {
151+
const reqUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
152+
153+
if (reqUrl.pathname !== callbackPath) {
154+
res.writeHead(404, { 'Content-Type': 'text/plain' })
155+
res.end('Not found')
156+
return
157+
}
158+
159+
const code = reqUrl.searchParams.get('code')
160+
if (!code) {
161+
res.writeHead(400, { 'Content-Type': 'text/html' })
162+
res.end(callbackPageHtml(false, 'No authorization code received.'))
163+
clearTimeout(timeout)
164+
stopChatGptOAuthServer()
165+
reject(new Error('No authorization code in callback'))
166+
return
167+
}
168+
169+
const state = reqUrl.searchParams.get('state')
170+
if (pendingState && (!state || state !== pendingState)) {
171+
res.writeHead(400, { 'Content-Type': 'text/html' })
172+
res.end(callbackPageHtml(false, 'OAuth state mismatch. Please try again.'))
173+
clearTimeout(timeout)
174+
stopChatGptOAuthServer()
175+
reject(new Error('OAuth state mismatch in callback'))
176+
return
177+
}
178+
179+
try {
180+
const fullCallbackUrl = `${CHATGPT_OAUTH_REDIRECT_URI}${reqUrl.search}`
181+
const credentials = await exchangeChatGptCodeForTokens(fullCallbackUrl, codeVerifier)
182+
183+
res.writeHead(200, { 'Content-Type': 'text/html' })
184+
res.end(callbackPageHtml(true))
185+
186+
clearTimeout(timeout)
187+
stopChatGptOAuthServer()
188+
resolve(credentials)
189+
} catch (err) {
190+
const message = err instanceof Error ? err.message : 'Token exchange failed'
191+
res.writeHead(500, { 'Content-Type': 'text/html' })
192+
res.end(callbackPageHtml(false, message))
193+
194+
clearTimeout(timeout)
195+
stopChatGptOAuthServer()
196+
reject(err instanceof Error ? err : new Error(message))
197+
}
198+
})
199+
200+
server.on('error', (err) => {
201+
clearTimeout(timeout)
202+
callbackServer = null
203+
reject(err)
204+
})
205+
206+
server.listen(port, '127.0.0.1', () => {
207+
callbackServer = server
208+
})
209+
})
210+
}
211+
212+
export function connectChatGptOAuth(): {
213+
authUrl: string
214+
credentials: Promise<ChatGptOAuthCredentials>
215+
} {
216+
stopChatGptOAuthServer()
217+
218+
const { codeVerifier, authUrl } = startChatGptOAuthFlow()
219+
const credentials = startCallbackServer(codeVerifier)
220+
221+
open(authUrl).catch(() => {
222+
console.debug(
223+
'Failed to open browser for ChatGPT OAuth. Manual URL:',
224+
authUrl,
225+
)
226+
})
227+
228+
return { authUrl, credentials }
106229
}
107230

108231
function parseAuthCodeInput(input: string): { code: string; state?: string } {
@@ -177,6 +300,7 @@ export async function exchangeChatGptCodeForTokens(
177300
}
178301

179302
export function disconnectChatGptOAuth(): void {
303+
stopChatGptOAuthServer()
180304
clearChatGptOAuthCredentials()
181305
resetChatGptOAuthRateLimit()
182306
}

common/src/constants/chatgpt-oauth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export const CHATGPT_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'
1818
/** Pinned redirect URI for paste-based localhost callback flow. */
1919
export const CHATGPT_OAUTH_REDIRECT_URI = 'http://localhost:1455/auth/callback'
2020

21-
/** Base URL for direct OpenAI API calls. */
22-
export const OPENAI_API_BASE_URL = 'https://api.openai.com'
21+
/** Base URL for ChatGPT backend API (Codex endpoint). */
22+
export const CHATGPT_BACKEND_BASE_URL = 'https://chatgpt.com/backend-api'
2323

2424
/** Environment variable for OAuth token override. */
2525
export const CHATGPT_OAUTH_TOKEN_ENV_VAR = 'CODEBUFF_CHATGPT_OAUTH_TOKEN'

sdk/src/__tests__/credentials.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ describe('credentials', () => {
527527
}
528528
})
529529

530-
test('clears credentials and returns null on refresh failure', async () => {
530+
test('preserves credentials and returns null on refresh failure', async () => {
531531
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refresh-fail-test-'))
532532
const env = { NEXT_PUBLIC_CB_ENVIRONMENT: 'test' } as any
533533
const originalHomedir = os.homedir
@@ -558,9 +558,10 @@ describe('credentials', () => {
558558
const result = await refreshClaudeOAuthToken(env)
559559

560560
expect(result).toBeNull()
561-
// Credentials should be cleared
561+
// Credentials should be preserved (not cleared) so future retries can attempt refresh again
562562
const saved = JSON.parse(fs.readFileSync(path.join(configDir, 'credentials.json'), 'utf8'))
563-
expect(saved.claudeOAuth).toBeUndefined()
563+
expect(saved.claudeOAuth).toBeDefined()
564+
expect(saved.claudeOAuth.refreshToken).toBe('invalid-refresh')
564565
} finally {
565566
;(os as any).homedir = originalHomedir
566567
fs.rmSync(tmpDir, { recursive: true })

sdk/src/credentials.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,7 @@ export const refreshClaudeOAuthToken = async (
255255
)
256256

257257
if (!response.ok) {
258-
// Refresh failed, clear credentials
259-
clearClaudeOAuthCredentials(clientEnv)
258+
console.debug(`Claude OAuth token refresh failed (status ${response.status})`)
260259
return null
261260
}
262261

@@ -273,9 +272,8 @@ export const refreshClaudeOAuthToken = async (
273272
saveClaudeOAuthCredentials(newCredentials, clientEnv)
274273

275274
return newCredentials
276-
} catch {
277-
// Refresh failed, clear credentials
278-
clearClaudeOAuthCredentials(clientEnv)
275+
} catch (error) {
276+
console.debug('Claude OAuth token refresh failed:', error instanceof Error ? error.message : String(error))
279277
return null
280278
} finally {
281279
// Clear the mutex after completion
@@ -434,7 +432,7 @@ export const refreshChatGptOAuthToken = async (
434432
})
435433

436434
if (!response.ok) {
437-
clearChatGptOAuthCredentials(clientEnv)
435+
console.debug(`ChatGPT OAuth token refresh failed (status ${response.status})`)
438436
return null
439437
}
440438

@@ -444,7 +442,7 @@ export const refreshChatGptOAuthToken = async (
444442
typeof data?.access_token !== 'string' ||
445443
data.access_token.trim().length === 0
446444
) {
447-
clearChatGptOAuthCredentials(clientEnv)
445+
console.debug('ChatGPT OAuth token refresh returned empty access token')
448446
return null
449447
}
450448

@@ -461,8 +459,8 @@ export const refreshChatGptOAuthToken = async (
461459
saveChatGptOAuthCredentials(newCredentials, clientEnv)
462460

463461
return newCredentials
464-
} catch {
465-
clearChatGptOAuthCredentials(clientEnv)
462+
} catch (error) {
463+
console.debug('ChatGPT OAuth token refresh failed:', error instanceof Error ? error.message : String(error))
466464
return null
467465
} finally {
468466
chatGptRefreshPromise = null

0 commit comments

Comments
 (0)