Skip to main content

codex_login/auth/
storage.rs

1use chrono::DateTime;
2use chrono::Utc;
3use serde::Deserialize;
4use serde::Serialize;
5use sha2::Digest;
6use sha2::Sha256;
7use std::collections::HashMap;
8use std::fmt::Debug;
9use std::fs::File;
10use std::fs::OpenOptions;
11use std::io::Read;
12use std::io::Write;
13#[cfg(unix)]
14use std::os::unix::fs::OpenOptionsExt;
15use std::path::Path;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::Mutex;
19use tracing::warn;
20
21use super::BedrockApiKeyAuth;
22use crate::token_data::TokenData;
23use codex_agent_identity::AgentIdentityJwtClaims;
24use codex_agent_identity::decode_agent_identity_jwt;
25use codex_config::types::AuthCredentialsStoreMode;
26pub use codex_config::types::AuthKeyringBackendKind;
27use codex_keyring_store::DefaultKeyringStore;
28use codex_keyring_store::KeyringStore;
29use codex_protocol::account::PlanType as AccountPlanType;
30use codex_protocol::auth::AuthMode;
31use codex_secrets::LocalSecretsNamespace;
32use codex_secrets::SecretName;
33use codex_secrets::SecretScope;
34use codex_secrets::SecretsBackendKind;
35use codex_secrets::SecretsManager;
36use once_cell::sync::Lazy;
37
38/// Expected structure for $CODEX_HOME/auth.json.
39#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
40pub struct AuthDotJson {
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub auth_mode: Option<AuthMode>,
43
44    #[serde(rename = "OPENAI_API_KEY")]
45    pub openai_api_key: Option<String>,
46
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub tokens: Option<TokenData>,
49
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub last_refresh: Option<DateTime<Utc>>,
52
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub agent_identity: Option<AgentIdentityStorage>,
55
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub personal_access_token: Option<String>,
58
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub bedrock_api_key: Option<BedrockApiKeyAuth>,
61}
62
63#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
64#[serde(untagged)]
65pub enum AgentIdentityStorage {
66    Jwt(String),
67    Record(AgentIdentityAuthRecord),
68}
69
70impl AgentIdentityStorage {
71    pub fn has_auth_material(&self) -> bool {
72        match self {
73            Self::Jwt(jwt) => !jwt.trim().is_empty(),
74            Self::Record(record) => {
75                !record.agent_runtime_id.trim().is_empty()
76                    && !record.agent_private_key.trim().is_empty()
77            }
78        }
79    }
80
81    pub(crate) fn as_record(&self) -> Option<&AgentIdentityAuthRecord> {
82        match self {
83            Self::Jwt(_) => None,
84            Self::Record(record) => Some(record),
85        }
86    }
87}
88
89#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
90pub struct AgentIdentityAuthRecord {
91    pub agent_runtime_id: String,
92    pub agent_private_key: String,
93    pub account_id: String,
94    pub chatgpt_user_id: String,
95    #[serde(
96        default,
97        deserialize_with = "deserialize_optional_non_empty_string",
98        serialize_with = "serialize_optional_string_as_empty"
99    )]
100    pub email: Option<String>,
101    pub plan_type: AccountPlanType,
102    pub chatgpt_account_is_fedramp: bool,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub task_id: Option<String>,
105}
106
107fn deserialize_optional_non_empty_string<'de, D>(
108    deserializer: D,
109) -> Result<Option<String>, D::Error>
110where
111    D: serde::Deserializer<'de>,
112{
113    Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
114}
115
116fn serialize_optional_string_as_empty<S>(
117    value: &Option<String>,
118    serializer: S,
119) -> Result<S::Ok, S::Error>
120where
121    S: serde::Serializer,
122{
123    value.as_deref().unwrap_or_default().serialize(serializer)
124}
125
126impl AgentIdentityAuthRecord {
127    pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result<Self> {
128        let claims =
129            decode_agent_identity_jwt(jwt, /*jwks*/ None).map_err(std::io::Error::other)?;
130
131        Ok(claims.into())
132    }
133}
134
135impl From<AgentIdentityJwtClaims> for AgentIdentityAuthRecord {
136    fn from(claims: AgentIdentityJwtClaims) -> Self {
137        Self {
138            agent_runtime_id: claims.agent_runtime_id,
139            agent_private_key: claims.agent_private_key,
140            account_id: claims.account_id,
141            chatgpt_user_id: claims.chatgpt_user_id,
142            email: claims.email,
143            plan_type: claims.plan_type.into(),
144            chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp,
145            task_id: None,
146        }
147    }
148}
149
150pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
151    codex_home.join("auth.json")
152}
153
154pub(super) fn delete_file_if_exists(codex_home: &Path) -> std::io::Result<bool> {
155    let auth_file = get_auth_file(codex_home);
156    match std::fs::remove_file(&auth_file) {
157        Ok(()) => Ok(true),
158        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
159        Err(err) => Err(err),
160    }
161}
162
163pub(super) trait AuthStorageBackend: Debug + Send + Sync {
164    fn load(&self) -> std::io::Result<Option<AuthDotJson>>;
165    fn save(&self, auth: &AuthDotJson) -> std::io::Result<()>;
166    fn delete(&self) -> std::io::Result<bool>;
167}
168
169#[derive(Clone, Debug)]
170pub(super) struct FileAuthStorage {
171    codex_home: PathBuf,
172}
173
174impl FileAuthStorage {
175    pub(super) fn new(codex_home: PathBuf) -> Self {
176        Self { codex_home }
177    }
178
179    /// Attempt to read and parse the `auth.json` file in the given `CODEX_HOME` directory.
180    /// Returns the full AuthDotJson structure.
181    pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result<AuthDotJson> {
182        let mut file = File::open(auth_file)?;
183        let mut contents = String::new();
184        file.read_to_string(&mut contents)?;
185        let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
186
187        Ok(auth_dot_json)
188    }
189}
190
191impl AuthStorageBackend for FileAuthStorage {
192    fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
193        let auth_file = get_auth_file(&self.codex_home);
194        let auth_dot_json = match self.try_read_auth_json(&auth_file) {
195            Ok(auth) => auth,
196            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
197            Err(err) => return Err(err),
198        };
199        Ok(Some(auth_dot_json))
200    }
201
202    fn save(&self, auth_dot_json: &AuthDotJson) -> std::io::Result<()> {
203        let auth_file = get_auth_file(&self.codex_home);
204
205        if let Some(parent) = auth_file.parent() {
206            std::fs::create_dir_all(parent)?;
207        }
208        let json_data = serde_json::to_string_pretty(auth_dot_json)?;
209        let mut options = OpenOptions::new();
210        options.truncate(true).write(true).create(true);
211        #[cfg(unix)]
212        {
213            options.mode(0o600);
214        }
215        let mut file = options.open(auth_file)?;
216        file.write_all(json_data.as_bytes())?;
217        file.flush()?;
218        Ok(())
219    }
220
221    fn delete(&self) -> std::io::Result<bool> {
222        delete_file_if_exists(&self.codex_home)
223    }
224}
225
226static CODEX_AUTH_SECRET_NAME: Lazy<SecretName> =
227    Lazy::new(|| match SecretName::new("CODEX_AUTH") {
228        Ok(name) => name,
229        Err(err) => unreachable!("CODEX_AUTH should be a valid secret name: {err}"),
230    });
231const KEYRING_SERVICE: &str = "Codex Auth";
232
233// turns codex_home path into a stable, short key string
234fn compute_store_key(codex_home: &Path) -> std::io::Result<String> {
235    let canonical = codex_home
236        .canonicalize()
237        .unwrap_or_else(|_| codex_home.to_path_buf());
238    let path_str = canonical.to_string_lossy();
239    let mut hasher = Sha256::new();
240    hasher.update(path_str.as_bytes());
241    let digest = hasher.finalize();
242    let hex = format!("{digest:x}");
243    let truncated = hex.get(..16).unwrap_or(&hex);
244    Ok(format!("cli|{truncated}"))
245}
246
247#[derive(Clone, Debug)]
248struct DirectKeyringAuthStorage {
249    codex_home: PathBuf,
250    keyring_store: Arc<dyn KeyringStore>,
251}
252
253impl DirectKeyringAuthStorage {
254    fn new(codex_home: PathBuf, keyring_store: Arc<dyn KeyringStore>) -> Self {
255        Self {
256            codex_home,
257            keyring_store,
258        }
259    }
260
261    fn load_from_keyring(&self, key: &str) -> std::io::Result<Option<AuthDotJson>> {
262        match self.keyring_store.load(KEYRING_SERVICE, key) {
263            Ok(Some(serialized)) => serde_json::from_str(&serialized).map(Some).map_err(|err| {
264                std::io::Error::other(format!(
265                    "failed to deserialize CLI auth from keyring: {err}"
266                ))
267            }),
268            Ok(None) => Ok(None),
269            Err(error) => Err(std::io::Error::other(format!(
270                "failed to load CLI auth from keyring: {}",
271                error.message()
272            ))),
273        }
274    }
275
276    fn save_to_keyring(&self, key: &str, value: &str) -> std::io::Result<()> {
277        match self.keyring_store.save(KEYRING_SERVICE, key, value) {
278            Ok(()) => Ok(()),
279            Err(error) => {
280                let message = format!(
281                    "failed to write OAuth tokens to keyring: {}",
282                    error.message()
283                );
284                warn!("{message}");
285                Err(std::io::Error::other(message))
286            }
287        }
288    }
289}
290
291impl AuthStorageBackend for DirectKeyringAuthStorage {
292    fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
293        let key = compute_store_key(&self.codex_home)?;
294        self.load_from_keyring(&key)
295    }
296
297    fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
298        let key = compute_store_key(&self.codex_home)?;
299        // Simpler error mapping per style: prefer method reference over closure
300        let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?;
301        self.save_to_keyring(&key, &serialized)?;
302        if let Err(err) = delete_file_if_exists(&self.codex_home) {
303            warn!("failed to remove CLI auth fallback file: {err}");
304        }
305        Ok(())
306    }
307
308    fn delete(&self) -> std::io::Result<bool> {
309        let key = compute_store_key(&self.codex_home)?;
310        let keyring_removed = self
311            .keyring_store
312            .delete(KEYRING_SERVICE, &key)
313            .map_err(|err| {
314                std::io::Error::other(format!("failed to delete auth from keyring: {err}"))
315            })?;
316        let file_removed = delete_file_if_exists(&self.codex_home)?;
317        Ok(keyring_removed || file_removed)
318    }
319}
320
321#[derive(Clone)]
322struct SecretsKeyringAuthStorage {
323    codex_home: PathBuf,
324    direct_storage: DirectKeyringAuthStorage,
325    secrets_manager: SecretsManager,
326}
327
328impl Debug for SecretsKeyringAuthStorage {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        f.debug_struct("SecretsKeyringAuthStorage")
331            .field("codex_home", &self.codex_home)
332            .finish_non_exhaustive()
333    }
334}
335
336impl SecretsKeyringAuthStorage {
337    fn new(codex_home: PathBuf, keyring_store: Arc<dyn KeyringStore>) -> Self {
338        let direct_storage =
339            DirectKeyringAuthStorage::new(codex_home.clone(), Arc::clone(&keyring_store));
340        let secrets_manager = SecretsManager::new_with_keyring_store_and_namespace(
341            codex_home.clone(),
342            SecretsBackendKind::Local,
343            keyring_store,
344            LocalSecretsNamespace::CodexAuth,
345        );
346        Self {
347            codex_home,
348            direct_storage,
349            secrets_manager,
350        }
351    }
352}
353
354impl AuthStorageBackend for SecretsKeyringAuthStorage {
355    fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
356        match self
357            .secrets_manager
358            .get(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME)
359            .map_err(|err| {
360                std::io::Error::other(format!(
361                    "failed to load CLI auth from encrypted auth storage: {err}"
362                ))
363            })? {
364            Some(serialized) => serde_json::from_str(&serialized).map(Some).map_err(|err| {
365                std::io::Error::other(format!(
366                    "failed to deserialize CLI auth from encrypted auth storage: {err}"
367                ))
368            }),
369            None => Ok(None),
370        }
371    }
372
373    fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
374        let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?;
375        self.secrets_manager
376            .set(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME, &serialized)
377            .map_err(|err| {
378                let message =
379                    format!("failed to write OAuth tokens to encrypted auth storage: {err}");
380                warn!("{message}");
381                std::io::Error::other(message)
382            })?;
383        if let Err(err) = delete_file_if_exists(&self.codex_home) {
384            warn!("failed to remove CLI auth fallback file: {err}");
385        }
386        Ok(())
387    }
388
389    fn delete(&self) -> std::io::Result<bool> {
390        let keyring_removed = self
391            .secrets_manager
392            .delete(&SecretScope::Global, &CODEX_AUTH_SECRET_NAME)
393            .map_err(|err| {
394                std::io::Error::other(format!(
395                    "failed to delete auth from encrypted auth storage: {err}"
396                ))
397            })?;
398        let file_removed = delete_file_if_exists(&self.codex_home)?;
399        let direct_removed = self.direct_storage.delete()?;
400        Ok(keyring_removed || file_removed || direct_removed)
401    }
402}
403
404#[derive(Clone, Debug)]
405struct AutoAuthStorage {
406    keyring_storage: Arc<dyn AuthStorageBackend>,
407    file_storage: Arc<FileAuthStorage>,
408}
409
410impl AutoAuthStorage {
411    fn new(
412        codex_home: PathBuf,
413        keyring_store: Arc<dyn KeyringStore>,
414        keyring_backend_kind: AuthKeyringBackendKind,
415    ) -> Self {
416        Self {
417            keyring_storage: create_keyring_auth_storage(
418                codex_home.clone(),
419                keyring_store,
420                keyring_backend_kind,
421            ),
422            file_storage: Arc::new(FileAuthStorage::new(codex_home)),
423        }
424    }
425}
426
427impl AuthStorageBackend for AutoAuthStorage {
428    fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
429        match self.keyring_storage.load() {
430            Ok(Some(auth)) => Ok(Some(auth)),
431            Ok(None) => self.file_storage.load(),
432            Err(err) => {
433                warn!("failed to load CLI auth from keyring, falling back to file storage: {err}");
434                self.file_storage.load()
435            }
436        }
437    }
438
439    fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
440        match self.keyring_storage.save(auth) {
441            Ok(()) => Ok(()),
442            Err(err) => {
443                warn!("failed to save auth to keyring, falling back to file storage: {err}");
444                self.file_storage.save(auth)
445            }
446        }
447    }
448
449    fn delete(&self) -> std::io::Result<bool> {
450        // Keyring storage will delete from disk as well
451        self.keyring_storage.delete()
452    }
453}
454
455// A global in-memory store for mapping codex_home -> AuthDotJson.
456static EPHEMERAL_AUTH_STORE: Lazy<Mutex<HashMap<String, AuthDotJson>>> =
457    Lazy::new(|| Mutex::new(HashMap::new()));
458
459#[derive(Clone, Debug)]
460struct EphemeralAuthStorage {
461    codex_home: PathBuf,
462}
463
464impl EphemeralAuthStorage {
465    fn new(codex_home: PathBuf) -> Self {
466        Self { codex_home }
467    }
468
469    fn with_store<F, T>(&self, action: F) -> std::io::Result<T>
470    where
471        F: FnOnce(&mut HashMap<String, AuthDotJson>, String) -> std::io::Result<T>,
472    {
473        let key = compute_store_key(&self.codex_home)?;
474        let mut store = EPHEMERAL_AUTH_STORE
475            .lock()
476            .map_err(|_| std::io::Error::other("failed to lock ephemeral auth storage"))?;
477        action(&mut store, key)
478    }
479}
480
481impl AuthStorageBackend for EphemeralAuthStorage {
482    fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
483        self.with_store(|store, key| Ok(store.get(&key).cloned()))
484    }
485
486    fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
487        self.with_store(|store, key| {
488            store.insert(key, auth.clone());
489            Ok(())
490        })
491    }
492
493    fn delete(&self) -> std::io::Result<bool> {
494        self.with_store(|store, key| Ok(store.remove(&key).is_some()))
495    }
496}
497
498pub(super) fn create_auth_storage(
499    codex_home: PathBuf,
500    mode: AuthCredentialsStoreMode,
501    keyring_backend_kind: AuthKeyringBackendKind,
502) -> Arc<dyn AuthStorageBackend> {
503    let keyring_store: Arc<dyn KeyringStore> = Arc::new(DefaultKeyringStore);
504    create_auth_storage_with_store(codex_home, mode, keyring_store, keyring_backend_kind)
505}
506
507fn create_auth_storage_with_store(
508    codex_home: PathBuf,
509    mode: AuthCredentialsStoreMode,
510    keyring_store: Arc<dyn KeyringStore>,
511    keyring_backend_kind: AuthKeyringBackendKind,
512) -> Arc<dyn AuthStorageBackend> {
513    match mode {
514        AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)),
515        AuthCredentialsStoreMode::Keyring => {
516            create_keyring_auth_storage(codex_home, keyring_store, keyring_backend_kind)
517        }
518        AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(
519            codex_home,
520            keyring_store,
521            keyring_backend_kind,
522        )),
523        AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)),
524    }
525}
526
527fn create_keyring_auth_storage(
528    codex_home: PathBuf,
529    keyring_store: Arc<dyn KeyringStore>,
530    keyring_backend_kind: AuthKeyringBackendKind,
531) -> Arc<dyn AuthStorageBackend> {
532    match keyring_backend_kind {
533        AuthKeyringBackendKind::Direct => {
534            Arc::new(DirectKeyringAuthStorage::new(codex_home, keyring_store))
535        }
536        AuthKeyringBackendKind::Secrets => {
537            Arc::new(SecretsKeyringAuthStorage::new(codex_home, keyring_store))
538        }
539    }
540}
541
542#[cfg(test)]
543#[path = "storage_tests.rs"]
544mod tests;