Skip to main content

synaps_cli/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, 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    // Once we have the callback result (from either path) the local server
75    // has done its job. Shut it down BEFORE token exchange so that any
76    // failure on the network or persistence path doesn't leak the server
77    // task or the bound port.
78    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, &params).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, &params).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(&params)
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
197/// Validate user-pasted OAuth authorization input for the manual fallback flow.
198///
199/// Returns `Some(CallbackResult)` only if the input contains BOTH a `code`
200/// and a `state`. Pre-2026-04 code defaulted the missing `state` to the
201/// original CSRF token, which silently bypassed the state check. By
202/// requiring an explicit state from the pasted input, the downstream
203/// `result.state != state` comparison can actually do its job: a malicious
204/// or accidental paste with no state (or the wrong state) is rejected.
205fn 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    // ── manual_paste_to_callback: CSRF guard on the manual paste path ──
234
235    #[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        // CSRF regression guard: pasting a bare authorization code MUST NOT
248        // be treated as a valid callback. Without an embedded `state`, we
249        // have no way to verify the response originated from our flow.
250        // Pre-fix code silently used the original `state.clone()` as a
251        // fallback — bypassing the CSRF check entirely.
252        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}