1use chrono::DateTime;
2use chrono::Utc;
3use serde::Deserialize;
4use serde::Serialize;
5use std::env;
6use std::fs::File;
7use std::fs::OpenOptions;
8use std::fs::remove_file;
9use std::io::Read;
10use std::io::Write;
11#[cfg(unix)]
12use std::os::unix::fs::OpenOptionsExt;
13use std::path::Path;
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::sync::Mutex;
17use std::time::Duration;
18
19pub use crate::server::LoginServer;
20pub use crate::server::ServerOptions;
21pub use crate::server::ShutdownHandle;
22pub use crate::server::run_login_server;
23pub use crate::token_data::TokenData;
24use crate::token_data::parse_id_token;
25
26mod pkce;
27mod server;
28mod token_data;
29
30pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
31pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
32
33#[derive(Clone, Debug, PartialEq, Copy, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum AuthMode {
36 ApiKey,
37 ChatGPT,
38}
39
40#[derive(Debug, Clone)]
41pub struct CodexAuth {
42 pub mode: AuthMode,
43
44 api_key: Option<String>,
45 auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
46 auth_file: PathBuf,
47}
48
49impl PartialEq for CodexAuth {
50 fn eq(&self, other: &Self) -> bool {
51 self.mode == other.mode
52 }
53}
54
55impl CodexAuth {
56 pub fn from_api_key(api_key: &str) -> Self {
57 Self {
58 api_key: Some(api_key.to_owned()),
59 mode: AuthMode::ApiKey,
60 auth_file: PathBuf::new(),
61 auth_dot_json: Arc::new(Mutex::new(None)),
62 }
63 }
64
65 pub async fn refresh_token(&self) -> Result<String, std::io::Error> {
66 let token_data = self
67 .get_current_token_data()
68 .ok_or(std::io::Error::other("Token data is not available."))?;
69 let token = token_data.refresh_token;
70
71 let refresh_response = try_refresh_token(token)
72 .await
73 .map_err(std::io::Error::other)?;
74
75 let updated = update_tokens(
76 &self.auth_file,
77 refresh_response.id_token,
78 refresh_response.access_token,
79 refresh_response.refresh_token,
80 )
81 .await?;
82
83 if let Ok(mut auth_lock) = self.auth_dot_json.lock() {
84 *auth_lock = Some(updated.clone());
85 }
86
87 let access = match updated.tokens {
88 Some(t) => t.access_token,
89 None => {
90 return Err(std::io::Error::other(
91 "Token data is not available after refresh.",
92 ));
93 }
94 };
95 Ok(access)
96 }
97
98 pub fn from_codex_home(
101 codex_home: &Path,
102 preferred_auth_method: AuthMode,
103 ) -> std::io::Result<Option<CodexAuth>> {
104 load_auth(codex_home, true, preferred_auth_method)
105 }
106
107 pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
108 let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
109 match auth_dot_json {
110 Some(AuthDotJson {
111 tokens: Some(mut tokens),
112 last_refresh: Some(last_refresh),
113 ..
114 }) => {
115 if last_refresh < Utc::now() - chrono::Duration::days(28) {
116 let refresh_response = tokio::time::timeout(
117 Duration::from_secs(60),
118 try_refresh_token(tokens.refresh_token.clone()),
119 )
120 .await
121 .map_err(|_| {
122 std::io::Error::other("timed out while refreshing OpenAI API key")
123 })?
124 .map_err(std::io::Error::other)?;
125
126 let updated_auth_dot_json = update_tokens(
127 &self.auth_file,
128 refresh_response.id_token,
129 refresh_response.access_token,
130 refresh_response.refresh_token,
131 )
132 .await?;
133
134 tokens = updated_auth_dot_json
135 .tokens
136 .clone()
137 .ok_or(std::io::Error::other(
138 "Token data is not available after refresh.",
139 ))?;
140
141 #[expect(clippy::unwrap_used)]
142 let mut auth_lock = self.auth_dot_json.lock().unwrap();
143 *auth_lock = Some(updated_auth_dot_json);
144 }
145
146 Ok(tokens)
147 }
148 _ => Err(std::io::Error::other("Token data is not available.")),
149 }
150 }
151
152 pub async fn get_token(&self) -> Result<String, std::io::Error> {
153 match self.mode {
154 AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
155 AuthMode::ChatGPT => {
156 let id_token = self.get_token_data().await?.access_token;
157
158 Ok(id_token)
159 }
160 }
161 }
162
163 pub fn get_account_id(&self) -> Option<String> {
164 self.get_current_token_data()
165 .and_then(|t| t.account_id.clone())
166 }
167
168 pub fn get_plan_type(&self) -> Option<String> {
169 self.get_current_token_data()
170 .and_then(|t| t.id_token.chatgpt_plan_type.as_ref().map(|p| p.as_string()))
171 }
172
173 fn get_current_auth_json(&self) -> Option<AuthDotJson> {
174 #[expect(clippy::unwrap_used)]
175 self.auth_dot_json.lock().unwrap().clone()
176 }
177
178 fn get_current_token_data(&self) -> Option<TokenData> {
179 self.get_current_auth_json().and_then(|t| t.tokens.clone())
180 }
181
182 pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
184 let auth_dot_json = AuthDotJson {
185 openai_api_key: None,
186 tokens: Some(TokenData {
187 id_token: Default::default(),
188 access_token: "Access Token".to_string(),
189 refresh_token: "test".to_string(),
190 account_id: Some("account_id".to_string()),
191 }),
192 last_refresh: Some(Utc::now()),
193 };
194
195 let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
196 Self {
197 api_key: None,
198 mode: AuthMode::ChatGPT,
199 auth_file: PathBuf::new(),
200 auth_dot_json,
201 }
202 }
203}
204
205fn load_auth(
206 codex_home: &Path,
207 include_env_var: bool,
208 preferred_auth_method: AuthMode,
209) -> std::io::Result<Option<CodexAuth>> {
210 let auth_file = get_auth_file(codex_home);
214 let auth_dot_json = match try_read_auth_json(&auth_file) {
215 Ok(auth) => auth,
216 Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
219 return match read_openai_api_key_from_env() {
220 Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
221 None => Ok(None),
222 };
223 }
224 Err(e) => {
227 return Err(e);
228 }
229 };
230
231 let AuthDotJson {
232 openai_api_key: auth_json_api_key,
233 tokens,
234 last_refresh,
235 } = auth_dot_json;
236
237 if let Some(api_key) = &auth_json_api_key {
240 match &tokens {
244 Some(tokens) => {
245 if tokens.should_use_api_key(preferred_auth_method) {
246 return Ok(Some(CodexAuth::from_api_key(api_key)));
247 } else {
248 }
250 }
251 None => {
252 return Ok(Some(CodexAuth::from_api_key(api_key)));
257 }
258 }
259 }
260
261 Ok(Some(CodexAuth {
264 api_key: None,
265 mode: AuthMode::ChatGPT,
266 auth_file,
267 auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson {
268 openai_api_key: None,
269 tokens,
270 last_refresh,
271 }))),
272 }))
273}
274
275fn read_openai_api_key_from_env() -> Option<String> {
276 env::var(OPENAI_API_KEY_ENV_VAR)
277 .ok()
278 .filter(|s| !s.is_empty())
279}
280
281pub fn get_auth_file(codex_home: &Path) -> PathBuf {
282 codex_home.join("auth.json")
283}
284
285pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
288 let auth_file = get_auth_file(codex_home);
289 match remove_file(&auth_file) {
290 Ok(_) => Ok(true),
291 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
292 Err(err) => Err(err),
293 }
294}
295
296pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> {
297 let auth_dot_json = AuthDotJson {
298 openai_api_key: Some(api_key.to_string()),
299 tokens: None,
300 last_refresh: None,
301 };
302 write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
303}
304
305pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
308 let mut file = File::open(auth_file)?;
309 let mut contents = String::new();
310 file.read_to_string(&mut contents)?;
311 let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
312
313 Ok(auth_dot_json)
314}
315
316fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> {
317 let json_data = serde_json::to_string_pretty(auth_dot_json)?;
318 let mut options = OpenOptions::new();
319 options.truncate(true).write(true).create(true);
320 #[cfg(unix)]
321 {
322 options.mode(0o600);
323 }
324 let mut file = options.open(auth_file)?;
325 file.write_all(json_data.as_bytes())?;
326 file.flush()?;
327 Ok(())
328}
329
330async fn update_tokens(
331 auth_file: &Path,
332 id_token: String,
333 access_token: Option<String>,
334 refresh_token: Option<String>,
335) -> std::io::Result<AuthDotJson> {
336 let mut auth_dot_json = try_read_auth_json(auth_file)?;
337
338 let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
339 tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?;
340 if let Some(access_token) = access_token {
341 tokens.access_token = access_token.to_string();
342 }
343 if let Some(refresh_token) = refresh_token {
344 tokens.refresh_token = refresh_token.to_string();
345 }
346 auth_dot_json.last_refresh = Some(Utc::now());
347 write_auth_json(auth_file, &auth_dot_json)?;
348 Ok(auth_dot_json)
349}
350
351async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
352 let refresh_request = RefreshRequest {
353 client_id: CLIENT_ID,
354 grant_type: "refresh_token",
355 refresh_token,
356 scope: "openid profile email",
357 };
358
359 let client = reqwest::Client::new();
360 let response = client
361 .post("https://auth.openai.com/oauth/token")
362 .header("Content-Type", "application/json")
363 .json(&refresh_request)
364 .send()
365 .await
366 .map_err(std::io::Error::other)?;
367
368 if response.status().is_success() {
369 let refresh_response = response
370 .json::<RefreshResponse>()
371 .await
372 .map_err(std::io::Error::other)?;
373 Ok(refresh_response)
374 } else {
375 Err(std::io::Error::other(format!(
376 "Failed to refresh token: {}",
377 response.status()
378 )))
379 }
380}
381
382#[derive(Serialize)]
383struct RefreshRequest {
384 client_id: &'static str,
385 grant_type: &'static str,
386 refresh_token: String,
387 scope: &'static str,
388}
389
390#[derive(Deserialize, Clone)]
391struct RefreshResponse {
392 id_token: String,
393 access_token: Option<String>,
394 refresh_token: Option<String>,
395}
396
397#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
399pub struct AuthDotJson {
400 #[serde(rename = "OPENAI_API_KEY")]
401 pub openai_api_key: Option<String>,
402
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub tokens: Option<TokenData>,
405
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub last_refresh: Option<DateTime<Utc>>,
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::token_data::IdTokenInfo;
414 use crate::token_data::KnownPlan;
415 use crate::token_data::PlanType;
416 use base64::Engine;
417 use pretty_assertions::assert_eq;
418 use serde_json::json;
419 use tempfile::tempdir;
420
421 const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z";
422
423 #[test]
424 fn writes_api_key_and_loads_auth() {
425 let dir = tempdir().unwrap();
426 login_with_api_key(dir.path(), "sk-test-key").unwrap();
427 let auth = load_auth(dir.path(), false, AuthMode::ChatGPT)
428 .unwrap()
429 .unwrap();
430 assert_eq!(auth.mode, AuthMode::ApiKey);
431 assert_eq!(auth.api_key.as_deref(), Some("sk-test-key"));
432 }
433
434 #[test]
435 fn loads_from_env_var_if_env_var_exists() {
436 let dir = tempdir().unwrap();
437
438 let env_var = std::env::var(OPENAI_API_KEY_ENV_VAR);
439
440 if let Ok(env_var) = env_var {
441 let auth = load_auth(dir.path(), true, AuthMode::ChatGPT)
442 .unwrap()
443 .unwrap();
444 assert_eq!(auth.mode, AuthMode::ApiKey);
445 assert_eq!(auth.api_key, Some(env_var));
446 }
447 }
448
449 #[tokio::test]
450 async fn roundtrip_auth_dot_json() {
451 let codex_home = tempdir().unwrap();
452 write_auth_file(
453 AuthFileParams {
454 openai_api_key: None,
455 chatgpt_plan_type: "pro".to_string(),
456 },
457 codex_home.path(),
458 )
459 .expect("failed to write auth file");
460
461 let file = get_auth_file(codex_home.path());
462 let auth_dot_json = try_read_auth_json(&file).unwrap();
463 write_auth_json(&file, &auth_dot_json).unwrap();
464
465 let same_auth_dot_json = try_read_auth_json(&file).unwrap();
466 assert_eq!(auth_dot_json, same_auth_dot_json);
467 }
468
469 #[tokio::test]
470 async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
471 let codex_home = tempdir().unwrap();
472 let fake_jwt = write_auth_file(
473 AuthFileParams {
474 openai_api_key: None,
475 chatgpt_plan_type: "pro".to_string(),
476 },
477 codex_home.path(),
478 )
479 .expect("failed to write auth file");
480
481 let CodexAuth {
482 api_key,
483 mode,
484 auth_dot_json,
485 auth_file: _,
486 } = load_auth(codex_home.path(), false, AuthMode::ChatGPT)
487 .unwrap()
488 .unwrap();
489 assert_eq!(None, api_key);
490 assert_eq!(AuthMode::ChatGPT, mode);
491
492 let guard = auth_dot_json.lock().unwrap();
493 let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
494 assert_eq!(
495 &AuthDotJson {
496 openai_api_key: None,
497 tokens: Some(TokenData {
498 id_token: IdTokenInfo {
499 email: Some("user@example.com".to_string()),
500 chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
501 raw_jwt: fake_jwt,
502 },
503 access_token: "test-access-token".to_string(),
504 refresh_token: "test-refresh-token".to_string(),
505 account_id: None,
506 }),
507 last_refresh: Some(
508 DateTime::parse_from_rfc3339(LAST_REFRESH)
509 .unwrap()
510 .with_timezone(&Utc)
511 ),
512 },
513 auth_dot_json
514 )
515 }
516
517 #[tokio::test]
521 async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
522 let codex_home = tempdir().unwrap();
523 let fake_jwt = write_auth_file(
524 AuthFileParams {
525 openai_api_key: Some("sk-test-key".to_string()),
526 chatgpt_plan_type: "pro".to_string(),
527 },
528 codex_home.path(),
529 )
530 .expect("failed to write auth file");
531
532 let CodexAuth {
533 api_key,
534 mode,
535 auth_dot_json,
536 auth_file: _,
537 } = load_auth(codex_home.path(), false, AuthMode::ChatGPT)
538 .unwrap()
539 .unwrap();
540 assert_eq!(None, api_key);
541 assert_eq!(AuthMode::ChatGPT, mode);
542
543 let guard = auth_dot_json.lock().unwrap();
544 let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
545 assert_eq!(
546 &AuthDotJson {
547 openai_api_key: None,
548 tokens: Some(TokenData {
549 id_token: IdTokenInfo {
550 email: Some("user@example.com".to_string()),
551 chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
552 raw_jwt: fake_jwt,
553 },
554 access_token: "test-access-token".to_string(),
555 refresh_token: "test-refresh-token".to_string(),
556 account_id: None,
557 }),
558 last_refresh: Some(
559 DateTime::parse_from_rfc3339(LAST_REFRESH)
560 .unwrap()
561 .with_timezone(&Utc)
562 ),
563 },
564 auth_dot_json
565 )
566 }
567
568 #[tokio::test]
571 async fn enterprise_account_with_api_key_uses_chatgpt_auth() {
572 let codex_home = tempdir().unwrap();
573 write_auth_file(
574 AuthFileParams {
575 openai_api_key: Some("sk-test-key".to_string()),
576 chatgpt_plan_type: "enterprise".to_string(),
577 },
578 codex_home.path(),
579 )
580 .expect("failed to write auth file");
581
582 let CodexAuth {
583 api_key,
584 mode,
585 auth_dot_json,
586 auth_file: _,
587 } = load_auth(codex_home.path(), false, AuthMode::ChatGPT)
588 .unwrap()
589 .unwrap();
590 assert_eq!(Some("sk-test-key".to_string()), api_key);
591 assert_eq!(AuthMode::ApiKey, mode);
592
593 let guard = auth_dot_json.lock().expect("should unwrap");
594 assert!(guard.is_none(), "auth_dot_json should be None");
595 }
596
597 struct AuthFileParams {
598 openai_api_key: Option<String>,
599 chatgpt_plan_type: String,
600 }
601
602 fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result<String> {
603 let auth_file = get_auth_file(codex_home);
604 #[derive(Serialize)]
606 struct Header {
607 alg: &'static str,
608 typ: &'static str,
609 }
610 let header = Header {
611 alg: "none",
612 typ: "JWT",
613 };
614 let payload = serde_json::json!({
615 "email": "user@example.com",
616 "email_verified": true,
617 "https://api.openai.com/auth": {
618 "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53",
619 "chatgpt_plan_type": params.chatgpt_plan_type,
620 "chatgpt_user_id": "user-12345",
621 "user_id": "user-12345",
622 }
623 });
624 let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b);
625 let header_b64 = b64(&serde_json::to_vec(&header)?);
626 let payload_b64 = b64(&serde_json::to_vec(&payload)?);
627 let signature_b64 = b64(b"sig");
628 let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
629
630 let auth_json_data = json!({
631 "OPENAI_API_KEY": params.openai_api_key,
632 "tokens": {
633 "id_token": fake_jwt,
634 "access_token": "test-access-token",
635 "refresh_token": "test-refresh-token"
636 },
637 "last_refresh": LAST_REFRESH,
638 });
639 let auth_json = serde_json::to_string_pretty(&auth_json_data)?;
640 std::fs::write(auth_file, auth_json)?;
641
642 Ok(fake_jwt)
643 }
644
645 #[test]
646 fn id_token_info_handles_missing_fields() {
647 let header = serde_json::json!({"alg": "none", "typ": "JWT"});
649 let payload = serde_json::json!({"sub": "123"});
650 let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
651 .encode(serde_json::to_vec(&header).unwrap());
652 let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
653 .encode(serde_json::to_vec(&payload).unwrap());
654 let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"sig");
655 let jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
656
657 let info = parse_id_token(&jwt).expect("should parse");
658 assert!(info.email.is_none());
659 assert!(info.chatgpt_plan_type.is_none());
660 }
661
662 #[tokio::test]
663 async fn loads_api_key_from_auth_json() {
664 let dir = tempdir().unwrap();
665 let auth_file = dir.path().join("auth.json");
666 std::fs::write(
667 auth_file,
668 r#"
669 {
670 "OPENAI_API_KEY": "sk-test-key",
671 "tokens": null,
672 "last_refresh": null
673 }
674 "#,
675 )
676 .unwrap();
677
678 let auth = load_auth(dir.path(), false, AuthMode::ChatGPT)
679 .unwrap()
680 .unwrap();
681 assert_eq!(auth.mode, AuthMode::ApiKey);
682 assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
683
684 assert!(auth.get_token_data().await.is_err());
685 }
686
687 #[test]
688 fn logout_removes_auth_file() -> Result<(), std::io::Error> {
689 let dir = tempdir()?;
690 login_with_api_key(dir.path(), "sk-test-key")?;
691 assert!(dir.path().join("auth.json").exists());
692 let removed = logout(dir.path())?;
693 assert!(removed);
694 assert!(!dir.path().join("auth.json").exists());
695 Ok(())
696 }
697}