codex_cli/auth/
use_secret.rs1use 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(×tamp_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 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 unsafe { std::env::set_var(self.key, value) };
186 } else {
187 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}