1use std::{
2 env::var,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use super::{
8 AuthStatus, Error, Identity, IdentityName, PKG_NAME, UnknownAuthReason,
9 fs::{
10 AUTH_FILE, STORAGE_SUFFIX, absolutize, create_symlink, is_broken_identity_path,
11 normalize_path, replace_file,
12 },
13};
14
15#[derive(Debug, Clone)]
17pub struct CodexAuthManager {
18 codex_home: PathBuf,
19}
20
21impl CodexAuthManager {
22 pub fn from_env() -> Result<Self, Error> {
31 let path = var("CODEX_HOME")
32 .map(PathBuf::from)
33 .or_else(|_| var("HOME").map(|home| PathBuf::from(home).join(".codex")))
34 .map_err(|source| Error::Env { source })?;
35 Self::new(path)
36 }
37
38 pub fn new(codex_home: impl AsRef<Path>) -> Result<Self, Error> {
47 Ok(Self {
48 codex_home: absolutize(codex_home.as_ref())?,
49 })
50 }
51
52 #[must_use]
54 pub fn codex_home(&self) -> &Path {
55 &self.codex_home
56 }
57
58 pub fn status(&self) -> Result<AuthStatus, Error> {
64 if !self.codex_home.exists() {
65 return Ok(AuthStatus::CodexHomeMissing {
66 path: self.codex_home.clone(),
67 });
68 }
69
70 let auth_path = self.auth_path();
71 let metadata = match fs::symlink_metadata(&auth_path) {
72 Ok(metadata) => metadata,
73 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
74 return Ok(AuthStatus::None);
75 }
76 Err(source) => {
77 return Err(Error::Io {
78 action: "inspect auth file",
79 path: auth_path,
80 source,
81 });
82 }
83 };
84
85 let file_type = metadata.file_type();
86 if file_type.is_file() {
87 return Ok(AuthStatus::Native);
88 }
89 if file_type.is_symlink() {
90 return self.status_from_symlink(&auth_path);
91 }
92
93 Ok(AuthStatus::Unknown {
94 reason: UnknownAuthReason::AuthPathIsNotFileOrSymlink,
95 })
96 }
97
98 pub fn list(&self) -> Result<Vec<Identity>, Error> {
106 let status = self.status()?;
107 let active = match &status {
108 AuthStatus::Managed { identity } | AuthStatus::BrokenManaged { identity } => Some((
109 identity.clone(),
110 matches!(status, AuthStatus::BrokenManaged { .. }),
111 )),
112 AuthStatus::None
113 | AuthStatus::Native
114 | AuthStatus::CodexHomeMissing { .. }
115 | AuthStatus::Unknown { .. } => None,
116 };
117
118 let mut identities = Vec::new();
119 let manager_dir = self.manager_dir();
120 let entries = match fs::read_dir(&manager_dir) {
121 Ok(entries) => entries,
122 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
123 if let Some((identity, broken)) = active {
124 identities.push(Identity {
125 path: self.identity_path(&identity),
126 name: identity,
127 active: true,
128 broken,
129 });
130 }
131 return Ok(identities);
132 }
133 Err(source) => {
134 return Err(Error::Io {
135 action: "read identity directory",
136 path: manager_dir,
137 source,
138 });
139 }
140 };
141
142 for entry in entries {
143 let entry = entry.map_err(|source| Error::Io {
144 action: "read identity directory entry",
145 path: manager_dir.clone(),
146 source,
147 })?;
148 let file_name = entry.file_name();
149 let Some(file_name) = file_name.to_str() else {
150 continue;
151 };
152 let Some(name) = file_name.strip_suffix(STORAGE_SUFFIX) else {
153 continue;
154 };
155 let Ok(name) = IdentityName::try_from(name) else {
156 continue;
157 };
158 let path = entry.path();
159 let broken = is_broken_identity_path(&path)?;
160 let active_here = active
161 .as_ref()
162 .is_some_and(|(active_name, _)| active_name == &name);
163 identities.push(Identity {
164 path,
165 name,
166 active: active_here,
167 broken,
168 });
169 }
170
171 if let Some((active_name, active_broken)) = active
172 && !identities
173 .iter()
174 .any(|identity| identity.name == active_name)
175 {
176 identities.push(Identity {
177 path: self.identity_path(&active_name),
178 name: active_name,
179 active: true,
180 broken: active_broken,
181 });
182 }
183
184 identities.sort_by(|left, right| left.name.cmp(&right.name));
185 Ok(identities)
186 }
187
188 pub fn capture(&self, identity: &IdentityName, options: CaptureOptions) -> Result<(), Error> {
195 self.require_codex_home()?;
196 match self.status()? {
197 AuthStatus::Native => {}
198 AuthStatus::None
199 | AuthStatus::CodexHomeMissing { .. }
200 | AuthStatus::Managed { .. }
201 | AuthStatus::BrokenManaged { .. } => {
202 return Err(Error::NoNativeAuthFile);
203 }
204 AuthStatus::Unknown { reason } => return Err(Error::UnknownAuthState { reason }),
205 }
206
207 let manager_dir = self.manager_dir();
208 fs::create_dir_all(&manager_dir).map_err(|source| Error::Io {
209 action: "create identity directory",
210 path: manager_dir,
211 source,
212 })?;
213
214 let identity_path = self.identity_path(identity);
215 match fs::symlink_metadata(&identity_path) {
216 Ok(metadata) if metadata.file_type().is_file() && options.force => {}
217 Ok(metadata) if metadata.file_type().is_file() => {
218 return Err(Error::IdentityAlreadyExists {
219 name: identity.clone(),
220 });
221 }
222 Ok(_) => {
223 return Err(Error::IdentityBroken {
224 name: identity.clone(),
225 });
226 }
227 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
228 Err(source) => {
229 return Err(Error::Io {
230 action: "inspect identity",
231 path: identity_path,
232 source,
233 });
234 }
235 }
236
237 let auth_path = self.auth_path();
238 let tmp_path = self.create_temporary_auth_symlink(identity)?;
239 replace_file(&auth_path, &identity_path).map_err(|source| Error::Io {
240 action: "capture native auth file",
241 path: auth_path.clone(),
242 source,
243 })?;
244 replace_file(&tmp_path, &auth_path)
245 .map_err(|source| Error::Io {
246 action: "activate identity",
247 path: auth_path.clone(),
248 source,
249 })
250 .inspect_err(|_| {
251 let _ = fs::rename(&identity_path, &auth_path);
252 let _ = fs::remove_file(&tmp_path);
253 })
254 }
255
256 pub fn use_identity(&self, identity: &IdentityName, options: UseOptions) -> Result<(), Error> {
263 self.require_codex_home()?;
264 self.require_usable_identity(identity)?;
265
266 match self.status()? {
267 AuthStatus::None | AuthStatus::Managed { .. } | AuthStatus::BrokenManaged { .. } => {}
268 AuthStatus::Native if options.force => {
269 fs::remove_file(self.auth_path()).map_err(|source| Error::Io {
270 action: "discard native auth file",
271 path: self.auth_path(),
272 source,
273 })?;
274 }
275 AuthStatus::Native => return Err(Error::NativeAuthExists),
276 AuthStatus::CodexHomeMissing { path } => return Err(Error::CodexHomeMissing { path }),
277 AuthStatus::Unknown { reason } => return Err(Error::UnknownAuthState { reason }),
278 }
279
280 self.replace_auth_symlink(identity)
281 }
282
283 pub fn detach(&self, options: DetachOptions) -> Result<(), Error> {
290 match self.status()? {
291 AuthStatus::None | AuthStatus::CodexHomeMissing { .. } => Ok(()),
292 AuthStatus::Managed { .. } => remove_auth_file(self),
293 AuthStatus::BrokenManaged { .. } | AuthStatus::Native if options.force => {
294 remove_auth_file(self)
295 }
296 AuthStatus::BrokenManaged { identity } => Err(Error::IdentityBroken { name: identity }),
297 AuthStatus::Native => Err(Error::NativeAuthExists),
298 AuthStatus::Unknown { reason } => Err(Error::UnknownAuthState { reason }),
299 }
300 }
301
302 fn auth_path(&self) -> PathBuf {
303 self.codex_home.join(AUTH_FILE)
304 }
305
306 fn manager_dir(&self) -> PathBuf {
307 self.codex_home.join(PKG_NAME)
308 }
309
310 fn identity_path(&self, identity: &IdentityName) -> PathBuf {
311 self.manager_dir()
312 .join(format!("{identity}{STORAGE_SUFFIX}"))
313 }
314
315 fn relative_identity_path(identity: &IdentityName) -> PathBuf {
316 PathBuf::from(PKG_NAME).join(format!("{identity}{STORAGE_SUFFIX}"))
317 }
318
319 fn require_codex_home(&self) -> Result<(), Error> {
320 if self.codex_home.exists() {
321 Ok(())
322 } else {
323 Err(Error::CodexHomeMissing {
324 path: self.codex_home.clone(),
325 })
326 }
327 }
328
329 fn require_usable_identity(&self, identity: &IdentityName) -> Result<(), Error> {
330 let path = self.identity_path(identity);
331 match fs::symlink_metadata(&path) {
332 Ok(metadata) if metadata.file_type().is_file() => Ok(()),
333 Ok(_) => Err(Error::IdentityBroken {
334 name: identity.clone(),
335 }),
336 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
337 Err(Error::IdentityNotFound {
338 name: identity.clone(),
339 })
340 }
341 Err(source) => Err(Error::Io {
342 action: "inspect identity",
343 path,
344 source,
345 }),
346 }
347 }
348
349 fn status_from_symlink(&self, auth_path: &Path) -> Result<AuthStatus, Error> {
350 let target = fs::read_link(auth_path).map_err(|source| Error::Io {
351 action: "read auth symlink",
352 path: auth_path.to_path_buf(),
353 source,
354 })?;
355 let target = if target.is_absolute() {
356 target
357 } else {
358 self.codex_home.join(target)
359 };
360 let target = normalize_path(&target);
361 let manager_dir = normalize_path(&self.manager_dir());
362 let Ok(relative) = target.strip_prefix(&manager_dir) else {
363 return Ok(AuthStatus::Unknown {
364 reason: UnknownAuthReason::SymlinkTargetOutsideManagerDir,
365 });
366 };
367 if relative.components().count() != 1 {
368 return Ok(AuthStatus::Unknown {
369 reason: UnknownAuthReason::SymlinkTargetOutsideManagerDir,
370 });
371 }
372 let Some(file_name) = relative.file_name().and_then(|name| name.to_str()) else {
373 return Ok(AuthStatus::Unknown {
374 reason: UnknownAuthReason::SymlinkTargetHasInvalidIdentityName,
375 });
376 };
377 let Some(name) = file_name.strip_suffix(STORAGE_SUFFIX) else {
378 return Ok(AuthStatus::Unknown {
379 reason: UnknownAuthReason::SymlinkTargetHasInvalidIdentityName,
380 });
381 };
382 let identity = IdentityName::try_from(name).map_err(|_| Error::UnknownAuthState {
383 reason: UnknownAuthReason::SymlinkTargetHasInvalidIdentityName,
384 })?;
385 match fs::symlink_metadata(&target) {
386 Ok(metadata) if metadata.file_type().is_file() => Ok(AuthStatus::Managed { identity }),
387 Ok(_) | Err(_) => Ok(AuthStatus::BrokenManaged { identity }),
388 }
389 }
390
391 fn replace_auth_symlink(&self, identity: &IdentityName) -> Result<(), Error> {
392 let tmp_path = self.create_temporary_auth_symlink(identity)?;
393 replace_file(&tmp_path, &self.auth_path()).map_err(|source| Error::Io {
394 action: "activate identity",
395 path: self.auth_path(),
396 source,
397 })
398 }
399
400 fn create_temporary_auth_symlink(&self, identity: &IdentityName) -> Result<PathBuf, Error> {
401 let tmp_path = self.codex_home.join(format!(".{AUTH_FILE}.tmp"));
402 if tmp_path.exists() || tmp_path.is_symlink() {
403 fs::remove_file(&tmp_path).map_err(|source| Error::Io {
404 action: "remove stale temporary auth symlink",
405 path: tmp_path.clone(),
406 source,
407 })?;
408 }
409 create_symlink(Self::relative_identity_path(identity), &tmp_path).map_err(|source| {
410 Error::Io {
411 action: "create temporary auth symlink",
412 path: tmp_path.clone(),
413 source,
414 }
415 })?;
416 Ok(tmp_path)
417 }
418}
419
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
422pub struct CaptureOptions {
423 pub force: bool,
425}
426
427#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
429pub struct UseOptions {
430 pub force: bool,
432}
433
434#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
436pub struct DetachOptions {
437 pub force: bool,
439}
440
441fn remove_auth_file(manager: &CodexAuthManager) -> Result<(), Error> {
442 fs::remove_file(manager.auth_path()).map_err(|source| Error::Io {
443 action: "remove auth file",
444 path: manager.auth_path(),
445 source,
446 })
447}
448
449#[cfg(test)]
450mod tests {
451 use std::{
452 fs,
453 path::{Path, PathBuf},
454 time::{SystemTime, UNIX_EPOCH},
455 };
456
457 use crate::{
458 AuthStatus, CaptureOptions, CodexAuthManager, DetachOptions, Error, IdentityName,
459 UseOptions, fs::create_symlink,
460 };
461
462 #[test]
463 fn capture_moves_native_auth_and_marks_identity_active() {
464 let temp = TempHome::new();
465 fs::create_dir_all(temp.path()).unwrap();
466 fs::write(temp.path().join("auth.json"), "{}").unwrap();
467 let manager = CodexAuthManager::new(temp.path()).unwrap();
468 let identity = IdentityName::try_from("work").unwrap();
469
470 manager
471 .capture(&identity, CaptureOptions::default())
472 .unwrap();
473
474 assert_eq!(
475 manager.status().unwrap(),
476 AuthStatus::Managed {
477 identity: identity.clone()
478 }
479 );
480 assert_eq!(
481 fs::read_to_string(temp.path().join("codex-auth-manager/work.json")).unwrap(),
482 "{}"
483 );
484 assert_eq!(
485 fs::read_link(temp.path().join("auth.json")).unwrap(),
486 PathBuf::from("codex-auth-manager/work.json")
487 );
488 let identities = manager.list().unwrap();
489 assert_eq!(identities.len(), 1);
490 assert_eq!(identities[0].name, identity);
491 assert!(identities[0].active);
492 assert!(!identities[0].broken);
493 }
494
495 #[test]
496 fn capture_force_overwrites_existing_regular_identity() {
497 let temp = TempHome::new();
498 fs::create_dir_all(temp.path().join("codex-auth-manager")).unwrap();
499 fs::write(temp.path().join("auth.json"), "new").unwrap();
500 fs::write(temp.path().join("codex-auth-manager/work.json"), "old").unwrap();
501 let manager = CodexAuthManager::new(temp.path()).unwrap();
502 let identity = IdentityName::try_from("work").unwrap();
503
504 assert!(matches!(
505 manager.capture(&identity, CaptureOptions::default()),
506 Err(Error::IdentityAlreadyExists { .. })
507 ));
508 manager
509 .capture(&identity, CaptureOptions { force: true })
510 .unwrap();
511
512 assert_eq!(
513 fs::read_to_string(temp.path().join("codex-auth-manager/work.json")).unwrap(),
514 "new"
515 );
516 }
517
518 #[test]
519 fn use_identity_refuses_native_auth_without_force() {
520 let temp = TempHome::new();
521 fs::create_dir_all(temp.path().join("codex-auth-manager")).unwrap();
522 fs::write(temp.path().join("auth.json"), "native").unwrap();
523 fs::write(temp.path().join("codex-auth-manager/work.json"), "work").unwrap();
524 let manager = CodexAuthManager::new(temp.path()).unwrap();
525 let identity = IdentityName::try_from("work").unwrap();
526
527 assert!(matches!(
528 manager.use_identity(&identity, UseOptions::default()),
529 Err(Error::NativeAuthExists)
530 ));
531 manager
532 .use_identity(&identity, UseOptions { force: true })
533 .unwrap();
534
535 assert_eq!(manager.status().unwrap(), AuthStatus::Managed { identity });
536 }
537
538 #[test]
539 fn list_includes_broken_active_identity() {
540 let temp = TempHome::new();
541 fs::create_dir_all(temp.path()).unwrap();
542 create_symlink(
543 PathBuf::from("codex-auth-manager/work.json"),
544 temp.path().join("auth.json"),
545 )
546 .unwrap();
547 let manager = CodexAuthManager::new(temp.path()).unwrap();
548
549 assert_eq!(
550 manager.status().unwrap(),
551 AuthStatus::BrokenManaged {
552 identity: IdentityName::try_from("work").unwrap()
553 }
554 );
555 let identities = manager.list().unwrap();
556 assert_eq!(identities.len(), 1);
557 assert_eq!(identities[0].name.as_str(), "work");
558 assert!(identities[0].active);
559 assert!(identities[0].broken);
560 }
561
562 #[test]
563 fn detach_force_removes_broken_managed_link() {
564 let temp = TempHome::new();
565 fs::create_dir_all(temp.path()).unwrap();
566 create_symlink(
567 PathBuf::from("codex-auth-manager/work.json"),
568 temp.path().join("auth.json"),
569 )
570 .unwrap();
571 let manager = CodexAuthManager::new(temp.path()).unwrap();
572
573 assert!(matches!(
574 manager.detach(DetachOptions::default()),
575 Err(Error::IdentityBroken { .. })
576 ));
577 manager.detach(DetachOptions { force: true }).unwrap();
578 assert_eq!(manager.status().unwrap(), AuthStatus::None);
579 }
580
581 struct TempHome {
582 path: PathBuf,
583 }
584
585 impl TempHome {
586 fn new() -> Self {
587 let unique = SystemTime::now()
588 .duration_since(UNIX_EPOCH)
589 .unwrap()
590 .as_nanos();
591 Self {
592 path: std::env::temp_dir().join(format!(
593 "codex-auth-manager-test-{}-{unique}",
594 std::process::id()
595 )),
596 }
597 }
598
599 fn path(&self) -> &Path {
600 &self.path
601 }
602 }
603
604 impl Drop for TempHome {
605 fn drop(&mut self) {
606 let _ = fs::remove_dir_all(&self.path);
607 }
608 }
609}