|
4 | 4 | */ |
5 | 5 |
|
6 | 6 | import crypto from 'crypto' |
| 7 | +import http from 'http' |
7 | 8 |
|
8 | 9 | import { |
9 | 10 | CHATGPT_OAUTH_AUTHORIZE_URL, |
@@ -95,14 +96,136 @@ export function startChatGptOAuthFlow(): { codeVerifier: string; authUrl: string |
95 | 96 | authUrl.searchParams.set('code_challenge_method', 'S256') |
96 | 97 | authUrl.searchParams.set('state', state) |
97 | 98 | 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') |
98 | 102 |
|
99 | 103 | return { codeVerifier, authUrl: authUrl.toString() } |
100 | 104 | } |
101 | 105 |
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') |
| 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 } |
106 | 229 | } |
107 | 230 |
|
108 | 231 | function parseAuthCodeInput(input: string): { code: string; state?: string } { |
@@ -177,6 +300,7 @@ export async function exchangeChatGptCodeForTokens( |
177 | 300 | } |
178 | 301 |
|
179 | 302 | export function disconnectChatGptOAuth(): void { |
| 303 | + stopChatGptOAuthServer() |
180 | 304 | clearChatGptOAuthCredentials() |
181 | 305 | resetChatGptOAuthRateLimit() |
182 | 306 | } |
|
0 commit comments