Skip to main content

codex_cli/auth/
use_secret.rs

1use anyhow::Result;
2use std::path::{Path, PathBuf};
3
4use crate::auth;
5use crate::fs;
6use crate::paths;
7
8pub fn run(target: &str) -> Result<i32> {
9    if target.is_empty() {
10        eprintln!("codex-use: usage: codex-use <name|name.json|email>");
11        return Ok(64);
12    }
13
14    if target.contains('/') || target.contains("..") {
15        eprintln!("codex-use: invalid secret name: {target}");
16        return Ok(64);
17    }
18
19    let secret_dir = match paths::resolve_secret_dir() {
20        Some(dir) => dir,
21        None => {
22            eprintln!("codex-use: secret not found: {target}");
23            return Ok(1);
24        }
25    };
26
27    let is_email = target.contains('@');
28    let mut secret_name = target.to_string();
29    if !secret_name.ends_with(".json") && !is_email {
30        secret_name.push_str(".json");
31    }
32
33    if secret_dir.join(&secret_name).is_file() {
34        return apply_secret(&secret_dir, &secret_name);
35    }
36
37    match resolve_by_email(&secret_dir, target) {
38        ResolveResult::Exact(name) => apply_secret(&secret_dir, &name),
39        ResolveResult::Ambiguous { candidates } => {
40            eprintln!("codex-use: identifier matches multiple secrets: {target}");
41            eprintln!("codex-use: candidates: {}", candidates.join(", "));
42            Ok(2)
43        }
44        ResolveResult::NotFound => {
45            eprintln!("codex-use: secret not found: {target}");
46            Ok(1)
47        }
48    }
49}
50
51fn apply_secret(secret_dir: &Path, secret_name: &str) -> Result<i32> {
52    let source_file = secret_dir.join(secret_name);
53    if !source_file.is_file() {
54        eprintln!("codex: secret file {secret_name} not found");
55        return Ok(1);
56    }
57
58    let auth_file = match paths::resolve_auth_file() {
59        Some(path) => path,
60        None => return Ok(1),
61    };
62
63    if auth_file.is_file() {
64        let sync_result = crate::auth::sync::run()?;
65        if sync_result != 0 {
66            eprintln!("codex: failed to sync current auth before switching secrets");
67            return Ok(1);
68        }
69    }
70
71    let contents = std::fs::read(&source_file)?;
72    fs::write_atomic(&auth_file, &contents, fs::SECRET_FILE_MODE)?;
73
74    let iso = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
75    let timestamp_path = secret_timestamp_path(&auth_file)?;
76    fs::write_timestamp(&timestamp_path, iso.as_deref())?;
77
78    println!("codex: applied {secret_name} to {}", auth_file.display());
79    Ok(0)
80}
81
82fn resolve_by_email(secret_dir: &Path, target: &str) -> ResolveResult {
83    let query = target.to_lowercase();
84    let want_full = target.contains('@');
85
86    let mut matches = Vec::new();
87    if let Ok(entries) = std::fs::read_dir(secret_dir) {
88        for entry in entries.flatten() {
89            let path = entry.path();
90            if path.extension().and_then(|s| s.to_str()) != Some("json") {
91                continue;
92            }
93            let email = match auth::email_from_auth_file(&path) {
94                Ok(Some(value)) => value,
95                _ => continue,
96            };
97            let email_lower = email.to_lowercase();
98            if want_full {
99                if email_lower == query {
100                    matches.push(file_name(&path));
101                }
102            } else if let Some(local_part) = email_lower.split('@').next()
103                && local_part == query
104            {
105                matches.push(file_name(&path));
106            }
107        }
108    }
109
110    if matches.len() == 1 {
111        ResolveResult::Exact(matches.remove(0))
112    } else if matches.is_empty() {
113        ResolveResult::NotFound
114    } else {
115        ResolveResult::Ambiguous {
116            candidates: matches,
117        }
118    }
119}
120
121fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
122    let cache_dir = paths::resolve_secret_cache_dir()
123        .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
124    let name = target_file
125        .file_name()
126        .and_then(|name| name.to_str())
127        .unwrap_or("auth.json");
128    Ok(cache_dir.join(format!("{name}.timestamp")))
129}
130
131fn file_name(path: &Path) -> String {
132    path.file_name()
133        .and_then(|name| name.to_str())
134        .unwrap_or_default()
135        .to_string()
136}
137
138#[derive(Debug)]
139enum ResolveResult {
140    Exact(String),
141    Ambiguous { candidates: Vec<String> },
142    NotFound,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::{ResolveResult, file_name, resolve_by_email, secret_timestamp_path};
148    use pretty_assertions::assert_eq;
149    use std::path::Path;
150
151    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
152    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
153    const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
154
155    fn token(payload: &str) -> String {
156        format!("{HEADER}.{payload}.sig")
157    }
158
159    fn auth_json(payload: &str) -> String {
160        format!(
161            r#"{{"tokens":{{"id_token":"{}","access_token":"{}"}}}}"#,
162            token(payload),
163            token(payload)
164        )
165    }
166
167    struct EnvGuard {
168        key: &'static str,
169        old: Option<std::ffi::OsString>,
170    }
171
172    impl EnvGuard {
173        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
174            let old = std::env::var_os(key);
175            // SAFETY: tests mutate process env only in scoped guard usage.
176            unsafe { std::env::set_var(key, value) };
177            Self { key, old }
178        }
179    }
180
181    impl Drop for EnvGuard {
182        fn drop(&mut self) {
183            if let Some(value) = self.old.take() {
184                // SAFETY: tests restore process env only in scoped guard usage.
185                unsafe { std::env::set_var(self.key, value) };
186            } else {
187                // SAFETY: tests restore process env only in scoped guard usage.
188                unsafe { std::env::remove_var(self.key) };
189            }
190        }
191    }
192
193    #[test]
194    fn resolve_by_email_supports_full_and_local_part_lookup() {
195        let dir = tempfile::TempDir::new().expect("tempdir");
196        std::fs::write(dir.path().join("alpha.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha");
197        std::fs::write(dir.path().join("beta.json"), auth_json(PAYLOAD_BETA)).expect("beta");
198
199        match resolve_by_email(dir.path(), "alpha@example.com") {
200            ResolveResult::Exact(name) => assert_eq!(name, "alpha.json"),
201            other => panic!("expected exact alpha match, got {other:?}"),
202        }
203        match resolve_by_email(dir.path(), "beta") {
204            ResolveResult::Exact(name) => assert_eq!(name, "beta.json"),
205            other => panic!("expected exact beta match, got {other:?}"),
206        }
207    }
208
209    #[test]
210    fn resolve_by_email_reports_ambiguous_and_not_found() {
211        let dir = tempfile::TempDir::new().expect("tempdir");
212        std::fs::write(dir.path().join("alpha-1.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-1");
213        std::fs::write(dir.path().join("alpha-2.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-2");
214
215        match resolve_by_email(dir.path(), "alpha@example.com") {
216            ResolveResult::Ambiguous { candidates } => {
217                assert_eq!(candidates.len(), 2);
218                assert!(candidates.contains(&"alpha-1.json".to_string()));
219                assert!(candidates.contains(&"alpha-2.json".to_string()));
220            }
221            other => panic!("expected ambiguous match, got {other:?}"),
222        }
223
224        match resolve_by_email(dir.path(), "missing@example.com") {
225            ResolveResult::NotFound => {}
226            other => panic!("expected not found, got {other:?}"),
227        }
228    }
229
230    #[test]
231    fn secret_timestamp_path_uses_cache_dir_and_default_file_name() {
232        let dir = tempfile::TempDir::new().expect("tempdir");
233        let cache = dir.path().join("cache");
234        std::fs::create_dir_all(&cache).expect("cache");
235        let _guard = EnvGuard::set("CODEX_SECRET_CACHE_DIR", &cache);
236
237        let with_name =
238            secret_timestamp_path(Path::new("/tmp/demo-auth.json")).expect("timestamp path");
239        assert_eq!(with_name, cache.join("demo-auth.json.timestamp"));
240
241        let without_name = secret_timestamp_path(Path::new("")).expect("timestamp path");
242        assert_eq!(without_name, cache.join("auth.json.timestamp"));
243    }
244
245    #[test]
246    fn file_name_returns_empty_when_path_has_no_file_name() {
247        assert_eq!(file_name(Path::new("a/b/c.json")), "c.json");
248        assert_eq!(file_name(Path::new("")), "");
249    }
250}