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 auth::is_invalid_secret_target(target) {
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 secret_name = if is_email {
66        target.to_string()
67    } else {
68        auth::normalize_secret_file_name(target)
69    };
70
71    if secret_dir.join(&secret_name).is_file() {
72        let (code, auth_file) = apply_secret(&secret_dir, &secret_name, output_json)?;
73        if output_json && code == 0 {
74            output::emit_result(
75                "auth use",
76                AuthUseResult {
77                    target: target.to_string(),
78                    matched_secret: Some(secret_name),
79                    applied: true,
80                    auth_file: auth_file.unwrap_or_default(),
81                },
82            )?;
83        }
84        return Ok(code);
85    }
86
87    match resolve_by_email(&secret_dir, target) {
88        ResolveResult::Exact(name) => {
89            let (code, auth_file) = apply_secret(&secret_dir, &name, output_json)?;
90            if output_json && code == 0 {
91                output::emit_result(
92                    "auth use",
93                    AuthUseResult {
94                        target: target.to_string(),
95                        matched_secret: Some(name),
96                        applied: true,
97                        auth_file: auth_file.unwrap_or_default(),
98                    },
99                )?;
100            }
101            Ok(code)
102        }
103        ResolveResult::Ambiguous { candidates } => {
104            if output_json {
105                output::emit_error(
106                    "auth use",
107                    "ambiguous-secret",
108                    format!("codex-use: identifier matches multiple secrets: {target}"),
109                    Some(json!({
110                        "target": target,
111                        "candidates": candidates,
112                    })),
113                )?;
114            } else {
115                eprintln!("codex-use: identifier matches multiple secrets: {target}");
116                eprintln!("codex-use: candidates: {}", candidates.join(", "));
117            }
118            Ok(2)
119        }
120        ResolveResult::NotFound => {
121            if output_json {
122                output::emit_error(
123                    "auth use",
124                    "secret-not-found",
125                    format!("codex-use: secret not found: {target}"),
126                    Some(json!({
127                        "target": target,
128                    })),
129                )?;
130            } else {
131                eprintln!("codex-use: secret not found: {target}");
132            }
133            Ok(1)
134        }
135    }
136}
137
138fn apply_secret(
139    secret_dir: &Path,
140    secret_name: &str,
141    output_json: bool,
142) -> Result<(i32, Option<String>)> {
143    let source_file = secret_dir.join(secret_name);
144    if !source_file.is_file() {
145        if !output_json {
146            eprintln!("codex: secret file {secret_name} not found");
147        }
148        return Ok((1, None));
149    }
150
151    let auth_file = match paths::resolve_auth_file() {
152        Some(path) => path,
153        None => return Ok((1, None)),
154    };
155
156    if auth_file.is_file() {
157        let sync_result = crate::auth::sync::run_with_json(false)?;
158        if sync_result != 0 {
159            if !output_json {
160                eprintln!("codex: failed to sync current auth before switching secrets");
161            }
162            return Ok((1, None));
163        }
164    }
165
166    let contents = std::fs::read(&source_file)?;
167    fs::write_atomic(&auth_file, &contents, fs::SECRET_FILE_MODE)?;
168
169    let iso = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
170    let timestamp_path = secret_timestamp_path(&auth_file)?;
171    fs::write_timestamp(&timestamp_path, iso.as_deref())?;
172
173    if !output_json {
174        println!("codex: applied {secret_name} to {}", auth_file.display());
175    }
176    Ok((0, Some(auth_file.display().to_string())))
177}
178
179fn resolve_by_email(secret_dir: &Path, target: &str) -> ResolveResult {
180    let query = target.to_lowercase();
181    let want_full = target.contains('@');
182
183    let mut matches = Vec::new();
184    if let Ok(entries) = std::fs::read_dir(secret_dir) {
185        for entry in entries.flatten() {
186            let path = entry.path();
187            if path.extension().and_then(|s| s.to_str()) != Some("json") {
188                continue;
189            }
190            let email = match auth::email_from_auth_file(&path) {
191                Ok(Some(value)) => value,
192                _ => continue,
193            };
194            let email_lower = email.to_lowercase();
195            if want_full {
196                if email_lower == query {
197                    matches.push(file_name(&path));
198                }
199            } else if let Some(local_part) = email_lower.split('@').next()
200                && local_part == query
201            {
202                matches.push(file_name(&path));
203            }
204        }
205    }
206
207    if matches.len() == 1 {
208        ResolveResult::Exact(matches.remove(0))
209    } else if matches.is_empty() {
210        ResolveResult::NotFound
211    } else {
212        ResolveResult::Ambiguous {
213            candidates: matches,
214        }
215    }
216}
217
218fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
219    let cache_dir = paths::resolve_secret_cache_dir()
220        .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
221    let name = target_file
222        .file_name()
223        .and_then(|name| name.to_str())
224        .unwrap_or("auth.json");
225    Ok(cache_dir.join(format!("{name}.timestamp")))
226}
227
228fn file_name(path: &Path) -> String {
229    path.file_name()
230        .and_then(|name| name.to_str())
231        .unwrap_or_default()
232        .to_string()
233}
234
235#[derive(Debug)]
236enum ResolveResult {
237    Exact(String),
238    Ambiguous { candidates: Vec<String> },
239    NotFound,
240}
241
242#[cfg(test)]
243mod tests {
244    use super::{ResolveResult, file_name, resolve_by_email, secret_timestamp_path};
245    use nils_test_support::{EnvGuard, GlobalStateLock};
246    use pretty_assertions::assert_eq;
247    use std::path::Path;
248
249    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
250    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
251    const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
252
253    fn token(payload: &str) -> String {
254        format!("{HEADER}.{payload}.sig")
255    }
256
257    fn auth_json(payload: &str) -> String {
258        format!(
259            r#"{{"tokens":{{"id_token":"{}","access_token":"{}"}}}}"#,
260            token(payload),
261            token(payload)
262        )
263    }
264
265    #[test]
266    fn resolve_by_email_supports_full_and_local_part_lookup() {
267        let dir = tempfile::TempDir::new().expect("tempdir");
268        std::fs::write(dir.path().join("alpha.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha");
269        std::fs::write(dir.path().join("beta.json"), auth_json(PAYLOAD_BETA)).expect("beta");
270
271        match resolve_by_email(dir.path(), "alpha@example.com") {
272            ResolveResult::Exact(name) => assert_eq!(name, "alpha.json"),
273            other => panic!("expected exact alpha match, got {other:?}"),
274        }
275        match resolve_by_email(dir.path(), "beta") {
276            ResolveResult::Exact(name) => assert_eq!(name, "beta.json"),
277            other => panic!("expected exact beta match, got {other:?}"),
278        }
279    }
280
281    #[test]
282    fn resolve_by_email_reports_ambiguous_and_not_found() {
283        let dir = tempfile::TempDir::new().expect("tempdir");
284        std::fs::write(dir.path().join("alpha-1.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-1");
285        std::fs::write(dir.path().join("alpha-2.json"), auth_json(PAYLOAD_ALPHA)).expect("alpha-2");
286
287        match resolve_by_email(dir.path(), "alpha@example.com") {
288            ResolveResult::Ambiguous { candidates } => {
289                assert_eq!(candidates.len(), 2);
290                assert!(candidates.contains(&"alpha-1.json".to_string()));
291                assert!(candidates.contains(&"alpha-2.json".to_string()));
292            }
293            other => panic!("expected ambiguous match, got {other:?}"),
294        }
295
296        match resolve_by_email(dir.path(), "missing@example.com") {
297            ResolveResult::NotFound => {}
298            other => panic!("expected not found, got {other:?}"),
299        }
300    }
301
302    #[test]
303    fn secret_timestamp_path_uses_cache_dir_and_default_file_name() {
304        let lock = GlobalStateLock::new();
305        let dir = tempfile::TempDir::new().expect("tempdir");
306        let cache = dir.path().join("cache");
307        std::fs::create_dir_all(&cache).expect("cache");
308        let cache_value = cache.to_string_lossy().to_string();
309        let _guard = EnvGuard::set(&lock, "CODEX_SECRET_CACHE_DIR", &cache_value);
310
311        let with_name =
312            secret_timestamp_path(Path::new("/tmp/demo-auth.json")).expect("timestamp path");
313        assert_eq!(with_name, cache.join("demo-auth.json.timestamp"));
314
315        let without_name = secret_timestamp_path(Path::new("")).expect("timestamp path");
316        assert_eq!(without_name, cache.join("auth.json.timestamp"));
317    }
318
319    #[test]
320    fn file_name_returns_empty_when_path_has_no_file_name() {
321        assert_eq!(file_name(Path::new("a/b/c.json")), "c.json");
322        assert_eq!(file_name(Path::new("")), "");
323    }
324}