Skip to main content

agent_core/core/auth/
openai_codex.rs

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;
18/// The path OpenAI redirects to (fixed by the registered `REDIRECT_URI`). The
19/// local callback server must listen on exactly this path: the shared default
20/// is `/callback`, which answered the browser redirect with 404 and left the
21/// pasted URL as the only way to finish a login.
22const 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
38/// Login; `Some(key)` persists ONLY into that slot (`openai-codex` or
39/// `openai-codex@<label>`), `None` returns the credential unsaved.
40pub 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    // Once we have the callback result (from either path) the local server
95    // has done its job. Shut it down BEFORE token exchange so that any
96    // failure on the network or persistence path doesn't leak the server
97    // task or the bound port.
98    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, &params).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, &params).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
156/// Token-endpoint POST, failures typed at the source (no response or an
157/// unusable success body is transient; a status goes by `status_kind`).
158pub(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(&params)
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            // Only the RFC 6749 error fields: a proxy may echo the request,
177            // which carries the refresh token / code verifier.
178            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
253/// Validate user-pasted OAuth authorization input for the manual fallback flow.
254///
255/// Returns `Some(CallbackResult)` only if the input contains BOTH a `code`
256/// and a `state`. Pre-2026-04 code defaulted the missing `state` to the
257/// original CSRF token, which silently bypassed the state check. By
258/// requiring an explicit state from the pasted input, the downstream
259/// `result.state != state` comparison can actually do its job: a malicious
260/// or accidental paste with no state (or the wrong state) is rejected.
261fn 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
269/// Display-only identity (email claim) carried by a Codex access token, if
270/// present. Never a secret; used to label accounts in listings.
271pub 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    // ── manual_paste_to_callback: CSRF guard on the manual paste path ──
341
342    #[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        // CSRF regression guard: pasting a bare authorization code MUST NOT
354        // be treated as a valid callback. Without an embedded `state`, we
355        // have no way to verify the response originated from our flow.
356        // Pre-fix code silently used the original `state.clone()` as a
357        // fallback — bypassing the CSRF check entirely.
358        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}