Skip to main content

elph_ai/auth/oauth/
openai_codex.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4
5use base64::Engine;
6use rand::Rng;
7use serde_json::Value;
8
9use crate::auth::OAuthLoader;
10use crate::auth::lazy_oauth;
11use crate::auth::types::{AuthEvent, AuthLoginCallbacks, AuthPrompt, OAuthAuth, OAuthCredential};
12
13use super::callback::{parse_authorization_input, start_callback_server};
14use super::device_code::poll_oauth_device_code_flow;
15use super::device_code::{DeviceCodePollOptions, DeviceCodePollResult};
16use super::pkce::generate_pkce;
17
18pub const OPENAI_CODEX_BROWSER_LOGIN_METHOD: &str = "browser";
19pub const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD: &str = "device_code";
20
21const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
22const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
23const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
24const REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
25const DEVICE_USER_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode";
26const DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token";
27const DEVICE_VERIFICATION_URI: &str = "https://auth.openai.com/codex/device";
28const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback";
29const DEVICE_CODE_TIMEOUT_SECONDS: u64 = 15 * 60;
30const SCOPE: &str = "openid profile email offline_access";
31const JWT_CLAIM_PATH: &str = "https://api.openai.com/auth";
32
33pub fn openai_codex_oauth() -> OAuthAuth {
34    lazy_oauth("OpenAI (ChatGPT Plus/Pro)", openai_codex_oauth_loader())
35}
36
37pub fn openai_codex_oauth_loader() -> OAuthLoader {
38    Arc::new(|| Box::pin(async { openai_codex_oauth_impl() }))
39}
40
41fn openai_codex_oauth_impl() -> OAuthAuth {
42    OAuthAuth {
43        name: "OpenAI (ChatGPT Plus/Pro)".to_string(),
44        login: Arc::new(|callbacks: Arc<dyn AuthLoginCallbacks>| {
45            Box::pin(async move {
46                let method = callbacks
47                    .prompt(AuthPrompt::Select {
48                        message: "Select OpenAI Codex login method:".to_string(),
49                        options: vec![
50                            crate::auth::types::AuthSelectOption {
51                                id: OPENAI_CODEX_BROWSER_LOGIN_METHOD.to_string(),
52                                label: "Browser login (default)".to_string(),
53                                description: None,
54                            },
55                            crate::auth::types::AuthSelectOption {
56                                id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD.to_string(),
57                                label: "Device code login (headless)".to_string(),
58                                description: None,
59                            },
60                        ],
61                    })
62                    .await?;
63
64                let creds = if method == OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD {
65                    login_openai_codex_device_code(callbacks).await?
66                } else if method == OPENAI_CODEX_BROWSER_LOGIN_METHOD {
67                    login_openai_codex(callbacks).await?
68                } else {
69                    return Err(anyhow::anyhow!("Unknown OpenAI Codex login method: {method}"));
70                };
71                Ok(to_oauth_credential(creds))
72            })
73        }),
74        refresh: Arc::new(|credential| {
75            Box::pin(async move {
76                let creds = refresh_openai_codex_token(&credential.refresh).await?;
77                Ok(to_oauth_credential(creds))
78            })
79        }),
80        to_auth: Arc::new(|credential| {
81            Box::pin(async move {
82                Ok(crate::auth::types::ModelAuth {
83                    api_key: Some(credential.access),
84                    headers: None,
85                    base_url: None,
86                })
87            })
88        }),
89    }
90}
91
92pub struct CodexOAuthTokens {
93    access: String,
94    refresh: String,
95    expires: i64,
96    account_id: String,
97}
98
99fn to_oauth_credential(creds: CodexOAuthTokens) -> OAuthCredential {
100    OAuthCredential {
101        kind: "oauth".to_string(),
102        access: creds.access,
103        refresh: creds.refresh,
104        expires: creds.expires,
105        account_id: Some(creds.account_id),
106        enterprise_url: None,
107        available_model_ids: None,
108    }
109}
110
111pub async fn login_openai_codex(callbacks: Arc<dyn AuthLoginCallbacks>) -> anyhow::Result<CodexOAuthTokens> {
112    let (verifier, challenge) = generate_pkce().await;
113    let state = create_state();
114    let auth_url = build_authorize_url(&challenge, &state, "elph");
115    let server = start_callback_server(1455, "/auth/callback", Some(&state), "OpenAI authentication completed").await?;
116
117    callbacks.notify(AuthEvent::AuthUrl {
118        url: auth_url,
119        instructions: Some("A browser window should open. Complete login to finish.".to_string()),
120    });
121
122    let callbacks_for_manual = callbacks.clone();
123    let state_for_manual = state.clone();
124    let callback = tokio::select! {
125        result = server.wait_for_code(std::time::Duration::from_secs(600)) => result.map(|r| r.code),
126        input = async move {
127            callbacks_for_manual
128                .prompt(AuthPrompt::ManualCode {
129                    message: "Complete login in your browser, or paste the authorization code / redirect URL here:".to_string(),
130                    placeholder: Some(REDIRECT_URI.to_string()),
131                })
132                .await
133                .ok()
134                .and_then(|input| {
135                    let (code, state_parsed) = parse_authorization_input(&input);
136                    if let Some(ref s) = state_parsed
137                        && s != &state_for_manual {
138                            return None;
139                        }
140                    code
141                })
142        } => input,
143    };
144
145    let code = callback;
146
147    let code = code.ok_or_else(|| anyhow::anyhow!("Missing authorization code"))?;
148    exchange_authorization_code(&code, &verifier, REDIRECT_URI).await
149}
150
151pub async fn login_openai_codex_device_code(
152    callbacks: Arc<dyn AuthLoginCallbacks>,
153) -> anyhow::Result<CodexOAuthTokens> {
154    let device = start_device_auth().await?;
155    callbacks.notify(AuthEvent::DeviceCode {
156        user_code: device.user_code.clone(),
157        verification_uri: DEVICE_VERIFICATION_URI.to_string(),
158        interval_seconds: Some(device.interval_seconds as u32),
159        expires_in_seconds: Some(DEVICE_CODE_TIMEOUT_SECONDS as u32),
160    });
161    let token = poll_device_auth(&device).await?;
162    exchange_authorization_code(&token.authorization_code, &token.code_verifier, DEVICE_REDIRECT_URI).await
163}
164
165pub async fn refresh_openai_codex_token(refresh_token: &str) -> anyhow::Result<CodexOAuthTokens> {
166    let client = reqwest::Client::new();
167    let response = client
168        .post(TOKEN_URL)
169        .header("Content-Type", "application/x-www-form-urlencoded")
170        .body(format!(
171            "grant_type=refresh_token&refresh_token={}&client_id={CLIENT_ID}",
172            urlencoding_encode(refresh_token)
173        ))
174        .send()
175        .await?;
176    let status = response.status();
177    let text = response.text().await?;
178    if !status.is_success() {
179        return Err(anyhow::anyhow!("OpenAI Codex token refresh failed ({status}): {text}"));
180    }
181    let json: Value = serde_json::from_str(&text)?;
182    tokens_from_json(&json)
183}
184
185fn build_authorize_url(challenge: &str, state: &str, originator: &str) -> String {
186    format!(
187        "{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}&redirect_uri={}&scope={}&code_challenge={challenge}&code_challenge_method=S256&state={state}&id_token_add_organizations=true&codex_cli_simplified_flow=true&originator={originator}",
188        urlencoding_encode(REDIRECT_URI),
189        urlencoding_encode(SCOPE),
190    )
191}
192
193fn create_state() -> String {
194    let mut bytes = [0u8; 16];
195    rand::rng().fill_bytes(&mut bytes);
196    hex::encode(bytes)
197}
198
199struct DeviceAuthInfo {
200    device_auth_id: String,
201    user_code: String,
202    interval_seconds: u64,
203}
204
205struct DeviceTokenSuccess {
206    authorization_code: String,
207    code_verifier: String,
208}
209
210async fn start_device_auth() -> anyhow::Result<DeviceAuthInfo> {
211    let client = reqwest::Client::new();
212    let response = client
213        .post(DEVICE_USER_CODE_URL)
214        .header("Content-Type", "application/json")
215        .json(&serde_json::json!({ "client_id": CLIENT_ID }))
216        .send()
217        .await?;
218    let status = response.status();
219    let text = response.text().await?;
220    if status.as_u16() == 404 {
221        return Err(anyhow::anyhow!(
222            "OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL."
223        ));
224    }
225    if !status.is_success() {
226        return Err(anyhow::anyhow!("OpenAI Codex device code request failed ({status}): {text}"));
227    }
228    let json: Value = serde_json::from_str(&text)?;
229    Ok(DeviceAuthInfo {
230        device_auth_id: json["device_auth_id"]
231            .as_str()
232            .ok_or_else(|| anyhow::anyhow!("invalid device_auth_id"))?
233            .to_string(),
234        user_code: json["user_code"]
235            .as_str()
236            .ok_or_else(|| anyhow::anyhow!("invalid user_code"))?
237            .to_string(),
238        interval_seconds: json["interval"].as_u64().unwrap_or(5),
239    })
240}
241
242async fn poll_device_auth(device: &DeviceAuthInfo) -> anyhow::Result<DeviceTokenSuccess> {
243    let device_auth_id = device.device_auth_id.clone();
244    let user_code = device.user_code.clone();
245    poll_oauth_device_code_flow(DeviceCodePollOptions {
246        interval_seconds: Some(device.interval_seconds),
247        expires_in_seconds: Some(DEVICE_CODE_TIMEOUT_SECONDS),
248        wait_before_first_poll: false,
249        poll: Box::new(move || {
250            let device_auth_id = device_auth_id.clone();
251            let user_code = user_code.clone();
252            Box::pin(async move {
253                let client = reqwest::Client::new();
254                let response = client
255                    .post(DEVICE_TOKEN_URL)
256                    .header("Content-Type", "application/json")
257                    .json(&serde_json::json!({
258                        "device_auth_id": device_auth_id,
259                        "user_code": user_code,
260                    }))
261                    .send()
262                    .await;
263
264                let response = match response {
265                    Ok(r) => r,
266                    Err(e) => return DeviceCodePollResult::Failed { message: e.to_string() },
267                };
268
269                if response.status().is_success() {
270                    let json: Value = match response.json().await {
271                        Ok(v) => v,
272                        Err(e) => return DeviceCodePollResult::Failed { message: e.to_string() },
273                    };
274                    let code = json["authorization_code"].as_str();
275                    let verifier = json["code_verifier"].as_str();
276                    if let (Some(code), Some(verifier)) = (code, verifier) {
277                        return DeviceCodePollResult::Complete(DeviceTokenSuccess {
278                            authorization_code: code.to_string(),
279                            code_verifier: verifier.to_string(),
280                        });
281                    }
282                    return DeviceCodePollResult::Failed {
283                        message: format!("Invalid OpenAI Codex device auth token response: {json}"),
284                    };
285                }
286
287                if response.status().as_u16() == 403 || response.status().as_u16() == 404 {
288                    return DeviceCodePollResult::Pending;
289                }
290
291                let text = response.text().await.unwrap_or_default();
292                let error_code = serde_json::from_str::<Value>(&text).ok().and_then(|j| {
293                    j.get("error")
294                        .and_then(|e| e.as_str().or_else(|| e.get("code").and_then(|c| c.as_str())))
295                        .map(|s| s.to_string())
296                });
297
298                match error_code.as_deref() {
299                    Some("deviceauth_authorization_pending") => DeviceCodePollResult::Pending,
300                    Some("slow_down") => DeviceCodePollResult::SlowDown { interval_seconds: None },
301                    _ => DeviceCodePollResult::Failed {
302                        message: format!("OpenAI Codex device auth failed: {text}"),
303                    },
304                }
305            }) as Pin<Box<dyn Future<Output = DeviceCodePollResult<DeviceTokenSuccess>> + Send>>
306        }),
307    })
308    .await
309}
310
311async fn exchange_authorization_code(
312    code: &str,
313    verifier: &str,
314    redirect_uri: &str,
315) -> anyhow::Result<CodexOAuthTokens> {
316    let client = reqwest::Client::new();
317    let response = client
318        .post(TOKEN_URL)
319        .header("Content-Type", "application/x-www-form-urlencoded")
320        .body(format!(
321            "grant_type=authorization_code&client_id={CLIENT_ID}&code={}&code_verifier={}&redirect_uri={}",
322            urlencoding_encode(code),
323            urlencoding_encode(verifier),
324            urlencoding_encode(redirect_uri),
325        ))
326        .send()
327        .await?;
328    let status = response.status();
329    let text = response.text().await?;
330    if !status.is_success() {
331        return Err(anyhow::anyhow!("OpenAI Codex token exchange failed ({status}): {text}"));
332    }
333    let json: Value = serde_json::from_str(&text)?;
334    tokens_from_json(&json)
335}
336
337fn tokens_from_json(json: &Value) -> anyhow::Result<CodexOAuthTokens> {
338    let access = json["access_token"]
339        .as_str()
340        .ok_or_else(|| anyhow::anyhow!("missing access_token"))?;
341    let refresh = json["refresh_token"]
342        .as_str()
343        .ok_or_else(|| anyhow::anyhow!("missing refresh_token"))?;
344    let expires_in = json["expires_in"]
345        .as_u64()
346        .ok_or_else(|| anyhow::anyhow!("missing expires_in"))?;
347    let account_id = get_account_id(access)?;
348    Ok(CodexOAuthTokens {
349        access: access.to_string(),
350        refresh: refresh.to_string(),
351        expires: chrono::Utc::now().timestamp_millis() + (expires_in as i64 * 1000),
352        account_id,
353    })
354}
355
356fn get_account_id(access_token: &str) -> anyhow::Result<String> {
357    let parts: Vec<&str> = access_token.split('.').collect();
358    if parts.len() != 3 {
359        return Err(anyhow::anyhow!("Failed to extract accountId from token"));
360    }
361    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
362        .decode(parts[1])
363        .or_else(|_| base64::engine::general_purpose::STANDARD.decode(parts[1]))?;
364    let json: Value = serde_json::from_slice(&payload)?;
365    json.pointer(&format!("/{JWT_CLAIM_PATH}/chatgpt_account_id"))
366        .or_else(|| json.get("chatgpt_account_id"))
367        .and_then(|v| v.as_str())
368        .map(|s| s.to_string())
369        .ok_or_else(|| anyhow::anyhow!("No account ID in token"))
370}
371
372fn urlencoding_encode(s: &str) -> String {
373    url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
374}
375
376mod hex {
377    pub fn encode(bytes: [u8; 16]) -> String {
378        bytes.iter().map(|b| format!("{b:02x}")).collect()
379    }
380}