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}