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#[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, 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 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
233fn 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 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 self.keyring_storage.delete()
452 }
453}
454
455static 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;