Skip to main content

codex_cli/auth/
use_secret.rs

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