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, save_provider_auth,
9 start_callback_server, CallbackResult, OAuthCredentials,
10};
11
12const PROVIDER: &str = "openai-codex";
13const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
14const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
15const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
16const CALLBACK_PORT: u16 = 1455;
17const REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
18const SCOPE: &str = "openid profile email offline_access";
19const JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
20
21#[derive(Debug, Deserialize)]
22struct CodexTokenResponse {
23 access_token: String,
24 refresh_token: String,
25 expires_in: u64,
26}
27
28pub async fn login() -> std::result::Result<OAuthCredentials, String> {
29 let verifier = generate_code_verifier();
30 let challenge = generate_code_challenge(&verifier);
31 let state = generate_state();
32 let (rx, server_handle) = start_callback_server(state.clone(), CALLBACK_PORT).await?;
33 let auth_url = build_auth_url(&challenge, &state)?;
34
35 eprintln!("\n\x1b[1mOpening browser to sign in with ChatGPT...\x1b[0m\n");
36 if let Err(e) = open_browser(&auth_url) {
37 eprintln!("Could not open browser automatically: {}", e);
38 }
39 eprintln!("\x1b[2mIf the browser didn't open, visit this URL:\x1b[0m");
40 eprintln!("\x1b[36m{}\x1b[0m\n", auth_url);
41
42 let (manual_tx, manual_rx) = oneshot::channel::<CallbackResult>();
43 let stdin_task = tokio::spawn(async move {
44 eprintln!("\x1b[2mOr paste the redirect URL here (must include `state`):\x1b[0m");
45 let mut line = String::new();
46 let result = tokio::task::spawn_blocking(move || {
47 std::io::stdin().read_line(&mut line).ok();
48 line.trim().to_string()
49 })
50 .await;
51
52 if let Ok(input) = result {
53 match manual_paste_to_callback(&input) {
54 Some(callback) => {
55 let _ = manual_tx.send(callback);
56 }
57 None => {
58 eprintln!(
59 "\x1b[31m✗ Pasted input did not contain both `code` and `state`.\x1b[0m"
60 );
61 eprintln!(
62 "\x1b[2m Paste the full redirect URL (e.g. http://localhost:1455/auth/callback?code=…&state=…).\x1b[0m"
63 );
64 }
65 }
66 }
67 });
68
69 let result = tokio::select! {
70 callback = rx => callback.map_err(|_| "Callback channel closed".to_string())?,
71 manual = manual_rx => manual.map_err(|_| "Manual input channel closed".to_string())?,
72 };
73 stdin_task.abort();
74 server_handle.shutdown().await;
79
80 if result.state != state {
81 return Err("OAuth state mismatch -- possible CSRF attack".to_string());
82 }
83
84 eprintln!("\n\x1b[1mExchanging code for tokens...\x1b[0m");
85 let creds = exchange_code_for_tokens(&result.code, &verifier).await?;
86 save_provider_auth(PROVIDER, &creds)?;
87 Ok(creds)
88}
89
90pub async fn refresh_token(client: &Client, refresh: &str) -> std::result::Result<OAuthCredentials, String> {
91 let params = [
92 ("grant_type", "refresh_token"),
93 ("client_id", CLIENT_ID),
94 ("refresh_token", refresh),
95 ];
96 let token = token_request(client, ¶ms).await?;
97 credentials_from_token(token)
98}
99
100async fn exchange_code_for_tokens(
101 code: &str,
102 verifier: &str,
103) -> std::result::Result<OAuthCredentials, String> {
104 let client = Client::builder()
105 .connect_timeout(std::time::Duration::from_secs(10))
106 .timeout(std::time::Duration::from_secs(30))
107 .build()
108 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
109 let params = [
110 ("grant_type", "authorization_code"),
111 ("client_id", CLIENT_ID),
112 ("code", code),
113 ("code_verifier", verifier),
114 ("redirect_uri", REDIRECT_URI),
115 ];
116 let token = token_request(&client, ¶ms).await?;
117 credentials_from_token(token)
118}
119
120async fn token_request(
121 client: &Client,
122 params: &[(&str, &str)],
123) -> std::result::Result<CodexTokenResponse, String> {
124 let resp = client
125 .post(TOKEN_URL)
126 .header("Content-Type", "application/x-www-form-urlencoded")
127 .form(¶ms)
128 .send()
129 .await
130 .map_err(|e| format!("Token request failed: {}", e))?;
131
132 if !resp.status().is_success() {
133 let status = resp.status();
134 let text = resp.text().await.unwrap_or_default();
135 return Err(format!("Token request failed ({}): {}", status, text));
136 }
137
138 resp.json()
139 .await
140 .map_err(|e| format!("Failed to parse token response: {}", e))
141}
142
143fn credentials_from_token(token: CodexTokenResponse) -> std::result::Result<OAuthCredentials, String> {
144 let account_id = extract_account_id(&token.access_token)
145 .ok_or_else(|| "Failed to extract ChatGPT account id from token".to_string())?;
146 Ok(OAuthCredentials {
147 auth_type: "oauth".to_string(),
148 refresh: token.refresh_token,
149 access: token.access_token,
150 expires: crate::epoch_millis() + (token.expires_in * 1000) - (5 * 60 * 1000),
151 account_id: Some(account_id),
152 })
153}
154
155fn build_auth_url(challenge: &str, state: &str) -> std::result::Result<String, String> {
156 let mut url = url::Url::parse(AUTHORIZE_URL).map_err(|e| e.to_string())?;
157 url.query_pairs_mut()
158 .append_pair("response_type", "code")
159 .append_pair("client_id", CLIENT_ID)
160 .append_pair("redirect_uri", REDIRECT_URI)
161 .append_pair("scope", SCOPE)
162 .append_pair("code_challenge", challenge)
163 .append_pair("code_challenge_method", "S256")
164 .append_pair("state", state)
165 .append_pair("id_token_add_organizations", "true")
166 .append_pair("codex_cli_simplified_flow", "true")
167 .append_pair("originator", "synaps");
168 Ok(url.to_string())
169}
170
171fn parse_authorization_input(input: &str) -> Option<(String, Option<String>)> {
172 let value = input.trim();
173 if value.is_empty() {
174 return None;
175 }
176 if let Ok(url) = url::Url::parse(value) {
177 let code = url.query_pairs().find(|(k, _)| k == "code").map(|(_, v)| v.to_string())?;
178 let state = url.query_pairs().find(|(k, _)| k == "state").map(|(_, v)| v.to_string());
179 return Some((code, state));
180 }
181 if value.contains("code=") {
182 let params = url::form_urlencoded::parse(value.as_bytes());
183 let mut code = None;
184 let mut state = None;
185 for (key, val) in params {
186 match key.as_ref() {
187 "code" => code = Some(val.to_string()),
188 "state" => state = Some(val.to_string()),
189 _ => {}
190 }
191 }
192 return code.map(|code| (code, state));
193 }
194 Some((value.to_string(), None))
195}
196
197fn manual_paste_to_callback(input: &str) -> Option<CallbackResult> {
206 let (code, state) = parse_authorization_input(input)?;
207 Some(CallbackResult { code, state: state? })
208}
209
210pub fn extract_account_id(access_token: &str) -> Option<String> {
211 let payload = access_token.split('.').nth(1)?;
212 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
213 let json: Value = serde_json::from_slice(&decoded).ok()?;
214 json.get(JWT_CLAIM_PATH)?
215 .get("chatgpt_account_id")?
216 .as_str()
217 .filter(|s| !s.is_empty())
218 .map(ToString::to_string)
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
225
226 #[test]
227 fn parses_redirect_url() {
228 let parsed = parse_authorization_input("http://localhost:1455/auth/callback?code=abc&state=xyz").unwrap();
229 assert_eq!(parsed.0, "abc");
230 assert_eq!(parsed.1.as_deref(), Some("xyz"));
231 }
232
233 #[test]
236 fn manual_paste_accepts_full_redirect_url() {
237 let result = manual_paste_to_callback(
238 "http://localhost:1455/auth/callback?code=abc&state=xyz",
239 )
240 .expect("URL with code+state must be accepted");
241 assert_eq!(result.code, "abc");
242 assert_eq!(result.state, "xyz");
243 }
244
245 #[test]
246 fn manual_paste_rejects_bare_code() {
247 assert!(manual_paste_to_callback("abc123_some_bare_code").is_none());
253 }
254
255 #[test]
256 fn manual_paste_rejects_url_without_state() {
257 assert!(
258 manual_paste_to_callback("http://localhost:1455/auth/callback?code=abc").is_none(),
259 "URL missing `state` must be rejected — would otherwise bypass CSRF"
260 );
261 }
262
263 #[test]
264 fn manual_paste_rejects_code_hash_state_shorthand() {
265 assert!(
266 manual_paste_to_callback("abc#xyz").is_none(),
267 "Codex manual paste requires the full redirect URL or query string so the code/state source is explicit"
268 );
269 }
270
271 #[test]
272 fn manual_paste_rejects_empty_input() {
273 assert!(manual_paste_to_callback("").is_none());
274 assert!(manual_paste_to_callback(" ").is_none());
275 }
276
277 #[test]
278 fn manual_paste_rejects_url_with_only_state() {
279 assert!(
280 manual_paste_to_callback("http://localhost:1455/auth/callback?state=xyz").is_none(),
281 "URL missing `code` must be rejected"
282 );
283 }
284
285 #[test]
286 fn extracts_account_id_from_jwt() {
287 let payload = serde_json::json!({
288 JWT_CLAIM_PATH: { "chatgpt_account_id": "acct_123" }
289 });
290 let token = format!(
291 "x.{}.y",
292 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
293 );
294 assert_eq!(extract_account_id(&token).as_deref(), Some("acct_123"));
295 }
296}