1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use reqwest::Client;
3use serde::Deserialize;
4use serde_json::Value;
5use tokio::sync::oneshot;
6
7use super::{
8 generate_code_challenge, generate_code_verifier, generate_state, open_browser,
9 save_provider_auth, start_callback_server_at, CallbackOutcome, CallbackResult,
10 OAuthCredentials, RefreshFailure, CALLBACK_HOST,
11};
12
13const PROVIDER: &str = "openai-codex";
14const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
15const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
16const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
17const CALLBACK_PORT: u16 = 1455;
18const CALLBACK_PATH: &str = "/auth/callback";
23const REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
24const SCOPE: &str = "openid profile email offline_access";
25const JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
26
27#[derive(Debug, Deserialize)]
28pub(crate) struct CodexTokenResponse {
29 access_token: String,
30 refresh_token: String,
31 expires_in: u64,
32}
33
34pub async fn login() -> std::result::Result<OAuthCredentials, String> {
35 login_into(Some(PROVIDER)).await
36}
37
38pub async fn login_into(
41 persist_key: Option<&str>,
42) -> std::result::Result<OAuthCredentials, String> {
43 let verifier = generate_code_verifier();
44 let challenge = generate_code_challenge(&verifier);
45 let state = generate_state();
46 let (rx, server_handle) =
47 start_callback_server_at(state.clone(), CALLBACK_HOST, CALLBACK_PORT, CALLBACK_PATH)
48 .await?;
49 let auth_url = build_auth_url(&challenge, &state)?;
50
51 eprintln!("\n\x1b[1mOpening browser to sign in with ChatGPT...\x1b[0m\n");
52 if let Err(e) = open_browser(&auth_url) {
53 eprintln!("Could not open browser automatically: {}", e);
54 }
55 eprintln!("\x1b[2mIf the browser didn't open, visit this URL:\x1b[0m");
56 eprintln!("\x1b[36m{}\x1b[0m\n", auth_url);
57
58 let (manual_tx, manual_rx) = oneshot::channel::<CallbackResult>();
59 let stdin_task = tokio::spawn(async move {
60 eprintln!("\x1b[2mOr paste the redirect URL here (must include `state`):\x1b[0m");
61 let mut line = String::new();
62 let result = tokio::task::spawn_blocking(move || {
63 std::io::stdin().read_line(&mut line).ok();
64 line.trim().to_string()
65 })
66 .await;
67
68 if let Ok(input) = result {
69 match manual_paste_to_callback(&input) {
70 Some(callback) => {
71 let _ = manual_tx.send(callback);
72 }
73 None => {
74 eprintln!(
75 "\x1b[31m✗ Pasted input did not contain both `code` and `state`.\x1b[0m"
76 );
77 eprintln!(
78 "\x1b[2m Paste the full redirect URL (e.g. http://localhost:1455/auth/callback?code=…&state=…).\x1b[0m"
79 );
80 }
81 }
82 }
83 });
84
85 let result = tokio::select! {
86 callback = rx => match callback.map_err(|_| "Callback channel closed".to_string())? {
87 CallbackOutcome::Authorized(result) => result,
88 CallbackOutcome::Denied { error, description } => return Err(format!("OAuth denied: {}{}", error, description.map(|d| format!(": {d}")).unwrap_or_default())),
89 CallbackOutcome::Invalid => return Err("Invalid OAuth callback".into()),
90 },
91 manual = manual_rx => manual.map_err(|_| "Manual input channel closed".to_string())?,
92 };
93 stdin_task.abort();
94 server_handle.shutdown().await;
99
100 if result.state != state {
101 return Err("OAuth state mismatch -- possible CSRF attack".to_string());
102 }
103
104 eprintln!("\n\x1b[1mExchanging code for tokens...\x1b[0m");
105 let creds = exchange_code_for_tokens(&result.code, &verifier).await?;
106 if let Some(key) = persist_key {
107 save_provider_auth(key, &creds)?;
108 }
109 Ok(creds)
110}
111
112pub async fn refresh_token(
113 client: &Client,
114 refresh: &str,
115) -> std::result::Result<OAuthCredentials, RefreshFailure> {
116 let params = [
117 ("grant_type", "refresh_token"),
118 ("client_id", CLIENT_ID),
119 ("refresh_token", refresh),
120 ];
121 let token = token_request_at(client, TOKEN_URL, ¶ms).await?;
122 credentials_from_token(token).map_err(RefreshFailure::other)
123}
124
125async fn exchange_code_for_tokens(
126 code: &str,
127 verifier: &str,
128) -> std::result::Result<OAuthCredentials, String> {
129 let client = Client::builder()
130 .tls_built_in_webpki_certs(true)
131 .tls_built_in_native_certs(true)
132 .connect_timeout(std::time::Duration::from_secs(10))
133 .timeout(std::time::Duration::from_secs(30))
134 .build()
135 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
136 let params = [
137 ("grant_type", "authorization_code"),
138 ("client_id", CLIENT_ID),
139 ("code", code),
140 ("code_verifier", verifier),
141 ("redirect_uri", REDIRECT_URI),
142 ];
143 let token = token_request(&client, ¶ms).await?;
144 credentials_from_token(token)
145}
146
147async fn token_request(
148 client: &Client,
149 params: &[(&str, &str)],
150) -> std::result::Result<CodexTokenResponse, String> {
151 token_request_at(client, TOKEN_URL, params)
152 .await
153 .map_err(String::from)
154}
155
156pub(crate) async fn token_request_at(
159 client: &Client,
160 url: &str,
161 params: &[(&str, &str)],
162) -> std::result::Result<CodexTokenResponse, RefreshFailure> {
163 let resp = client
164 .post(url)
165 .header("Content-Type", "application/x-www-form-urlencoded")
166 .form(¶ms)
167 .send()
168 .await
169 .map_err(|e| RefreshFailure::transport(format!("Token request failed: {}", e)))?;
170
171 if !resp.status().is_success() {
172 let status = resp.status();
173 let text = resp.text().await.unwrap_or_default();
174 return Err(RefreshFailure::status(
175 status.as_u16(),
176 format!(
179 "Token request failed ({}): {}",
180 status,
181 super::token::sanitize_oauth_error_body(&text)
182 ),
183 ));
184 }
185
186 resp.json()
187 .await
188 .map_err(|e| RefreshFailure::bad_response(format!("Failed to parse token response: {}", e)))
189}
190
191fn credentials_from_token(
192 token: CodexTokenResponse,
193) -> std::result::Result<OAuthCredentials, String> {
194 let account_id = extract_account_id(&token.access_token)
195 .ok_or_else(|| "Failed to extract ChatGPT account id from token".to_string())?;
196 Ok(OAuthCredentials {
197 auth_type: "oauth".to_string(),
198 refresh: token.refresh_token,
199 access: token.access_token,
200 expires: crate::epoch_millis() + (token.expires_in * 1000) - (5 * 60 * 1000),
201 account_id: Some(account_id),
202 })
203}
204
205fn build_auth_url(challenge: &str, state: &str) -> std::result::Result<String, String> {
206 let mut url = url::Url::parse(AUTHORIZE_URL).map_err(|e| e.to_string())?;
207 url.query_pairs_mut()
208 .append_pair("response_type", "code")
209 .append_pair("client_id", CLIENT_ID)
210 .append_pair("redirect_uri", REDIRECT_URI)
211 .append_pair("scope", SCOPE)
212 .append_pair("code_challenge", challenge)
213 .append_pair("code_challenge_method", "S256")
214 .append_pair("state", state)
215 .append_pair("id_token_add_organizations", "true")
216 .append_pair("codex_cli_simplified_flow", "true")
217 .append_pair("originator", "synaps");
218 Ok(url.to_string())
219}
220
221fn parse_authorization_input(input: &str) -> Option<(String, Option<String>)> {
222 let value = input.trim();
223 if value.is_empty() {
224 return None;
225 }
226 if let Ok(url) = url::Url::parse(value) {
227 let code = url
228 .query_pairs()
229 .find(|(k, _)| k == "code")
230 .map(|(_, v)| v.to_string())?;
231 let state = url
232 .query_pairs()
233 .find(|(k, _)| k == "state")
234 .map(|(_, v)| v.to_string());
235 return Some((code, state));
236 }
237 if value.contains("code=") {
238 let params = url::form_urlencoded::parse(value.as_bytes());
239 let mut code = None;
240 let mut state = None;
241 for (key, val) in params {
242 match key.as_ref() {
243 "code" => code = Some(val.to_string()),
244 "state" => state = Some(val.to_string()),
245 _ => {}
246 }
247 }
248 return code.map(|code| (code, state));
249 }
250 Some((value.to_string(), None))
251}
252
253fn manual_paste_to_callback(input: &str) -> Option<CallbackResult> {
262 let (code, state) = parse_authorization_input(input)?;
263 Some(CallbackResult {
264 code,
265 state: state?,
266 })
267}
268
269pub fn extract_email(access_token: &str) -> Option<String> {
272 let payload = access_token.split('.').nth(1)?;
273 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
274 let json: Value = serde_json::from_slice(&decoded).ok()?;
275 json.get("https://api.openai.com/profile")?
276 .get("email")?
277 .as_str()
278 .map(str::trim)
279 .filter(|s| !s.is_empty() && s.len() <= 254)
280 .map(ToString::to_string)
281}
282
283pub fn extract_account_id(access_token: &str) -> Option<String> {
284 let payload = access_token.split('.').nth(1)?;
285 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
286 let json: Value = serde_json::from_slice(&decoded).ok()?;
287 json.get(JWT_CLAIM_PATH)?
288 .get("chatgpt_account_id")?
289 .as_str()
290 .filter(|s| !s.is_empty())
291 .map(ToString::to_string)
292}
293
294#[cfg(test)]
295mod tests {
296 #[test]
297 fn callback_server_listens_where_openai_redirects() {
298 let redirect = url::Url::parse(REDIRECT_URI).unwrap();
299 assert_eq!(redirect.path(), CALLBACK_PATH);
300 assert_eq!(redirect.port(), Some(CALLBACK_PORT));
301 }
302
303 #[tokio::test]
304 async fn browser_redirect_to_auth_callback_completes_the_login() {
305 let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
306 let port = probe.local_addr().unwrap().port();
307 drop(probe);
308 let (rx, handle) =
309 start_callback_server_at("st8".into(), CALLBACK_HOST, port, CALLBACK_PATH)
310 .await
311 .unwrap();
312 let response = reqwest::get(format!(
313 "http://127.0.0.1:{port}{CALLBACK_PATH}?code=c0de&state=st8"
314 ))
315 .await
316 .unwrap();
317 assert!(response.status().is_success(), "{}", response.status());
318 assert_eq!(
319 rx.await.unwrap(),
320 CallbackOutcome::Authorized(CallbackResult {
321 code: "c0de".into(),
322 state: "st8".into(),
323 })
324 );
325 handle.shutdown().await;
326 }
327
328 use super::*;
329 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
330
331 #[test]
332 fn parses_redirect_url() {
333 let parsed =
334 parse_authorization_input("http://localhost:1455/auth/callback?code=abc&state=xyz")
335 .unwrap();
336 assert_eq!(parsed.0, "abc");
337 assert_eq!(parsed.1.as_deref(), Some("xyz"));
338 }
339
340 #[test]
343 fn manual_paste_accepts_full_redirect_url() {
344 let result =
345 manual_paste_to_callback("http://localhost:1455/auth/callback?code=abc&state=xyz")
346 .expect("URL with code+state must be accepted");
347 assert_eq!(result.code, "abc");
348 assert_eq!(result.state, "xyz");
349 }
350
351 #[test]
352 fn manual_paste_rejects_bare_code() {
353 assert!(manual_paste_to_callback("abc123_some_bare_code").is_none());
359 }
360
361 #[test]
362 fn manual_paste_rejects_url_without_state() {
363 assert!(
364 manual_paste_to_callback("http://localhost:1455/auth/callback?code=abc").is_none(),
365 "URL missing `state` must be rejected — would otherwise bypass CSRF"
366 );
367 }
368
369 #[test]
370 fn manual_paste_rejects_code_hash_state_shorthand() {
371 assert!(
372 manual_paste_to_callback("abc#xyz").is_none(),
373 "Codex manual paste requires the full redirect URL or query string so the code/state source is explicit"
374 );
375 }
376
377 #[test]
378 fn manual_paste_rejects_empty_input() {
379 assert!(manual_paste_to_callback("").is_none());
380 assert!(manual_paste_to_callback(" ").is_none());
381 }
382
383 #[test]
384 fn manual_paste_rejects_url_with_only_state() {
385 assert!(
386 manual_paste_to_callback("http://localhost:1455/auth/callback?state=xyz").is_none(),
387 "URL missing `code` must be rejected"
388 );
389 }
390
391 #[test]
392 fn extracts_account_id_from_jwt() {
393 let payload = serde_json::json!({
394 JWT_CLAIM_PATH: { "chatgpt_account_id": "acct_123" }
395 });
396 let token = format!(
397 "x.{}.y",
398 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
399 );
400 assert_eq!(extract_account_id(&token).as_deref(), Some("acct_123"));
401 }
402}