1use anyhow::{Context, Result};
2use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3use reqwest::Client;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::sync::Arc;
7use std::time::{Duration, SystemTime};
8use tokio::sync::Mutex;
9use tokio::time::sleep;
10
11pub const CODEX_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
13pub const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
14pub const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
15pub const CODEX_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
16
17#[derive(Debug, Deserialize)]
18#[allow(dead_code)]
19pub struct CodeXTokenResponse {
20 pub id_token: String,
21 pub access_token: String,
22 pub refresh_token: String,
23 pub expires_in: Option<u64>,
24 pub token_type: String,
25}
26
27#[derive(Debug, Serialize)]
28#[allow(dead_code)]
29struct CodeXTokenRequest {
30 grant_type: String,
31 code: String,
32 redirect_uri: String,
33 client_id: String,
34 code_verifier: String,
35}
36
37#[derive(Debug, Serialize)]
38#[allow(dead_code)]
39struct CodeXRefreshTokenRequest {
40 grant_type: String,
41 refresh_token: String,
42 client_id: String,
43}
44
45#[derive(Debug, Deserialize)]
46struct CodeXErrorResponse {
47 error: String,
48 error_description: Option<String>,
49}
50
51pub struct CodexOAuthClient {
53 client: Client,
54 client_id: String,
55 redirect_uri: String,
56}
57
58impl Default for CodexOAuthClient {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl CodexOAuthClient {
65 pub fn new() -> Self {
66 Self {
67 client: Client::new(),
68 client_id: CODEX_CLIENT_ID.to_string(),
69 redirect_uri: CODEX_REDIRECT_URI.to_string(),
70 }
71 }
72
73 fn generate_pkce() -> Result<(String, String)> {
75 let mut bytes = [0u8; 32];
77 generate_random_bytes(&mut bytes)?;
78 let verifier = URL_SAFE_NO_PAD.encode(bytes);
79
80 let mut hasher = Sha256::new();
82 hasher.update(verifier.as_bytes());
83 let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
84
85 Ok((verifier, challenge))
86 }
87
88 fn generate_state() -> String {
90 let mut bytes = [0u8; 32];
91 generate_random_bytes(&mut bytes).unwrap_or_default();
92 URL_SAFE_NO_PAD.encode(bytes)
93 }
94
95 pub fn get_authorization_url(&self) -> Result<(String, String)> {
97 let (verifier, challenge) = Self::generate_pkce()?;
98 let state = Self::generate_state();
99
100 let params = [
101 ("client_id", self.client_id.as_str()),
102 ("redirect_uri", self.redirect_uri.as_str()),
103 ("response_type", "code"),
104 ("scope", "openid profile email offline_access"),
105 ("state", state.as_str()),
106 ("code_challenge", challenge.as_str()),
107 ("code_challenge_method", "S256"),
108 ("id_token_add_organizations", "true"),
109 ("codex_cli_simplified_flow", "true"),
110 ("originator", "rusty-commit"),
111 ];
112
113 let query = serde_urlencoded::to_string(params).context("Failed to encode OAuth params")?;
114 let auth_url = format!("{}?{}", CODEX_AUTHORIZE_URL, query);
115
116 Ok((auth_url, verifier))
117 }
118
119 pub async fn start_callback_server(&self, verifier: String) -> Result<CodeXTokenResponse> {
121 use warp::Filter;
122
123 let code = Arc::new(Mutex::new(None));
124 let code_clone = code.clone();
125
126 let callback = warp::path("auth")
128 .and(warp::path("callback"))
129 .and(warp::query::<std::collections::HashMap<String, String>>())
130 .map(move |params: std::collections::HashMap<String, String>| {
131 if let Some(auth_code) = params.get("code") {
132 let mut code_lock = code_clone.blocking_lock();
133 *code_lock = Some(auth_code.clone());
134 }
135
136 warp::reply::html(r#"
137 <!DOCTYPE html>
138 <html>
139 <head>
140 <title>Authentication Successful</title>
141 <style>
142 body {
143 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
144 display: flex;
145 justify-content: center;
146 align-items: center;
147 height: 100vh;
148 margin: 0;
149 background: linear-gradient(135deg, #10a37f 0%, #1a7f64 100%);
150 }
151 .container {
152 background: white;
153 padding: 3rem;
154 border-radius: 12px;
155 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
156 text-align: center;
157 max-width: 400px;
158 }
159 h1 { color: #1a7f64; margin-bottom: 1rem; }
160 p { color: #666; line-height: 1.6; }
161 .check {
162 width: 60px;
163 height: 60px;
164 margin: 0 auto 1.5rem;
165 background: #10a37f;
166 border-radius: 50%;
167 display: flex;
168 align-items: center;
169 justify-content: center;
170 }
171 .check::after {
172 content: '✓';
173 color: white;
174 font-size: 30px;
175 font-weight: bold;
176 }
177 </style>
178 </head>
179 <body>
180 <div class="container">
181 <div class="check"></div>
182 <h1>Authentication Successful!</h1>
183 <p>You can now close this window and return to your terminal.</p>
184 </div>
185 </body>
186 </html>
187 "#)
188 });
189
190 let server = warp::serve(callback).bind(([127, 0, 0, 1], 1455));
192 let server_handle = tokio::spawn(server);
193
194 let start = SystemTime::now();
196 let timeout = Duration::from_secs(300); loop {
199 if let Some(auth_code) = &*code.lock().await {
200 let token = self.exchange_code_for_token(auth_code, &verifier).await?;
202 server_handle.abort();
203 return Ok(token);
204 }
205
206 if SystemTime::now().duration_since(start)? > timeout {
207 server_handle.abort();
208 anyhow::bail!("Authentication timeout - no response received");
209 }
210
211 sleep(Duration::from_millis(100)).await;
212 }
213 }
214
215 async fn exchange_code_for_token(
217 &self,
218 code: &str,
219 verifier: &str,
220 ) -> Result<CodeXTokenResponse> {
221 let params = [
222 ("grant_type", "authorization_code"),
223 ("code", code),
224 ("redirect_uri", &self.redirect_uri),
225 ("client_id", &self.client_id),
226 ("code_verifier", verifier),
227 ];
228
229 let response = self
230 .client
231 .post(CODEX_TOKEN_URL)
232 .form(¶ms)
233 .send()
234 .await
235 .context("Failed to exchange code for token")?;
236
237 if response.status().is_success() {
238 response
239 .json::<CodeXTokenResponse>()
240 .await
241 .context("Failed to parse token response")
242 } else {
243 let error: CodeXErrorResponse = response.json().await?;
244 anyhow::bail!(
245 "Token exchange failed: {} - {}",
246 error.error,
247 error.error_description.unwrap_or_default()
248 )
249 }
250 }
251
252 #[allow(dead_code)]
254 pub async fn refresh_token(&self, refresh_token: &str) -> Result<CodeXTokenResponse> {
255 let params = [
256 ("grant_type", "refresh_token"),
257 ("refresh_token", refresh_token),
258 ("client_id", &self.client_id),
259 ];
260
261 let response = self
262 .client
263 .post(CODEX_TOKEN_URL)
264 .form(¶ms)
265 .send()
266 .await
267 .context("Failed to refresh token")?;
268
269 if response.status().is_success() {
270 response
271 .json::<CodeXTokenResponse>()
272 .await
273 .context("Failed to parse refresh token response")
274 } else {
275 let error: CodeXErrorResponse = response.json().await?;
276 anyhow::bail!(
277 "Token refresh failed: {} - {}",
278 error.error,
279 error.error_description.unwrap_or_default()
280 )
281 }
282 }
283}
284
285fn generate_random_bytes(dest: &mut [u8]) -> Result<()> {
287 use rand::RngCore;
288 let mut rng = rand::rng();
289 rng.fill_bytes(dest);
290 Ok(())
291}