Skip to main content

agcodex_login/
lib.rs

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    /// Loads the available auth information from the auth.json or
99    /// OPENAI_API_KEY environment variable.
100    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    /// Consider this private to integration tests.
183    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    // First, check to see if there is a valid auth.json file. If not, we fall
211    // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
212    // (if it is set).
213    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        // If auth.json does not exist, try to read the OPENAI_API_KEY from the
217        // environment variable.
218        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        // Though if auth.json exists but is malformed, do not fall back to the
225        // env var because the user may be expecting to use AuthMode::ChatGPT.
226        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 the auth.json has an API key AND does not appear to be on a plan that
238    // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
239    if let Some(api_key) = &auth_json_api_key {
240        // Should any of these be AuthMode::ChatGPT with the api_key set?
241        // Does AuthMode::ChatGPT indicate that there is an auth.json that is
242        // "refreshable" even if we are using the API key for auth?
243        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                    // Ignore the API key and fall through to ChatGPT auth.
249                }
250            }
251            None => {
252                // We have an API key but no tokens in the auth.json file.
253                // Perhaps the user ran `codex login --api-key <KEY>` or updated
254                // auth.json by hand. Either way, let's assume they are trying
255                // to use their API key.
256                return Ok(Some(CodexAuth::from_api_key(api_key)));
257            }
258        }
259    }
260
261    // For the AuthMode::ChatGPT variant, perhaps neither api_key nor
262    // openai_api_key should exist?
263    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
285/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
286/// if a file was removed, `Ok(false)` if no auth file was present.
287pub 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
305/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
306/// Returns the full AuthDotJson structure after refreshing if necessary.
307pub 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/// Expected structure for $CODEX_HOME/auth.json.
398#[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    /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in
518    /// [`TokenData::is_plan_that_should_use_api_key`], it should use
519    /// [`AuthMode::ChatGPT`].
520    #[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    /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise
569    /// account, then it should use [`AuthMode::ApiKey`].
570    #[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        // Create a minimal valid JWT for the id_token field.
605        #[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        // Payload without email or plan should yield None values.
648        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}