Skip to main content

cc_switch/codex/
auth_writer.rs

1use crate::codex::CodexConfiguration;
2use anyhow::{Result, anyhow};
3use serde_json::json;
4use std::fs;
5use std::path::PathBuf;
6
7/// Build the auth.json path, using home directory or an override for testing
8fn get_auth_path(base_dir: Option<&PathBuf>) -> Result<PathBuf> {
9    let codex_dir = match base_dir {
10        Some(dir) => dir.join(".codex"),
11        None => dirs::home_dir()
12            .ok_or_else(|| anyhow!("Could not find home directory"))?
13            .join(".codex"),
14    };
15
16    if !codex_dir.exists() {
17        fs::create_dir_all(&codex_dir)
18            .map_err(|e| anyhow!("Failed to create .codex directory: {}", e))?;
19    }
20
21    Ok(codex_dir.join("auth.json"))
22}
23
24/// Build the default path to `~/.codex/auth.json` without creating any
25/// directories.
26///
27/// This is a read-only lookup, distinct from `get_auth_path` which both
28/// resolves the path and creates the `.codex` directory as a side effect
29/// for the write path. Intended for callers that need to read the user's
30/// existing auth file (e.g., importing into a cc-switch configuration).
31pub fn default_codex_auth_path() -> Result<PathBuf> {
32    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?;
33    Ok(default_codex_auth_path_in(&home))
34}
35
36/// Inner helper: build the `<home>/.codex/auth.json` path without any
37/// filesystem side effects. Exposed for tests.
38fn default_codex_auth_path_in(home: &std::path::Path) -> PathBuf {
39    home.join(".codex").join("auth.json")
40}
41
42/// Write CodexConfiguration to ~/.codex/auth.json
43pub fn write_auth_json(config: &CodexConfiguration) -> Result<()> {
44    let auth_path = get_auth_path(None)?;
45    write_auth_json_to_path(config, &auth_path)
46}
47
48/// Write CodexConfiguration to a specific path (for testing)
49fn write_auth_json_to_path(config: &CodexConfiguration, auth_path: &PathBuf) -> Result<()> {
50    let json_value = if config.auth_mode == "apikey" {
51        json!({
52            "auth_mode": "apikey",
53            "OPENAI_API_KEY": config.openai_api_key,
54            "tokens": null
55        })
56    } else {
57        json!({
58            "auth_mode": "chatgpt",
59            "OPENAI_API_KEY": config.openai_api_key,
60            "tokens": {
61                "id_token": config.id_token,
62                "access_token": config.access_token,
63                "refresh_token": config.refresh_token,
64                "account_id": config.account_id
65            },
66            "last_refresh": config.last_refresh
67        })
68    };
69
70    let json_string = serde_json::to_string_pretty(&json_value)
71        .map_err(|e| anyhow!("Failed to serialize auth.json: {}", e))?;
72
73    // Ensure parent directory exists
74    if let Some(parent) = auth_path.parent()
75        && !parent.exists()
76    {
77        fs::create_dir_all(parent).map_err(|e| anyhow!("Failed to create directory: {}", e))?;
78    }
79
80    fs::write(auth_path, json_string).map_err(|e| anyhow!("Failed to write auth.json: {}", e))?;
81
82    Ok(())
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use tempfile::TempDir;
89
90    #[test]
91    fn test_write_auth_json_chatgpt_mode() {
92        let temp_dir = TempDir::new().expect("Should create temp dir");
93        let auth_path = temp_dir.path().join(".codex").join("auth.json");
94
95        let config = CodexConfiguration {
96            alias_name: "test".to_string(),
97            auth_mode: "chatgpt".to_string(),
98            openai_api_key: None,
99            id_token: Some("test_id".to_string()),
100            access_token: Some("test_access".to_string()),
101            refresh_token: Some("test_refresh".to_string()),
102            account_id: Some("test_account".to_string()),
103            last_refresh: Some("2026-05-16T00:00:00Z".to_string()),
104        };
105
106        let result = write_auth_json_to_path(&config, &auth_path);
107        assert!(result.is_ok());
108
109        let content = fs::read_to_string(&auth_path).expect("Should read file");
110        let parsed: serde_json::Value = serde_json::from_str(&content).expect("Should parse");
111
112        assert_eq!(parsed["auth_mode"], "chatgpt");
113        assert_eq!(parsed["tokens"]["id_token"], "test_id");
114        assert_eq!(parsed["tokens"]["access_token"], "test_access");
115        assert_eq!(parsed["tokens"]["refresh_token"], "test_refresh");
116        assert_eq!(parsed["tokens"]["account_id"], "test_account");
117    }
118
119    #[test]
120    fn test_write_auth_json_apikey_mode() {
121        let temp_dir = TempDir::new().expect("Should create temp dir");
122        let auth_path = temp_dir.path().join(".codex").join("auth.json");
123
124        let config = CodexConfiguration {
125            alias_name: "test".to_string(),
126            auth_mode: "apikey".to_string(),
127            openai_api_key: Some("sk-ant-test-key".to_string()),
128            id_token: None,
129            access_token: None,
130            refresh_token: None,
131            account_id: None,
132            last_refresh: None,
133        };
134
135        let result = write_auth_json_to_path(&config, &auth_path);
136        assert!(result.is_ok());
137
138        let content = fs::read_to_string(&auth_path).expect("Should read file");
139        let parsed: serde_json::Value = serde_json::from_str(&content).expect("Should parse");
140
141        assert_eq!(parsed["auth_mode"], "apikey");
142        assert_eq!(parsed["OPENAI_API_KEY"], "sk-ant-test-key");
143        assert!(parsed["tokens"].is_null());
144    }
145
146    #[test]
147    fn test_default_codex_auth_path_ends_correctly() {
148        let path = default_codex_auth_path().expect("Should resolve default codex auth path");
149        let path_str = path.to_string_lossy();
150        assert!(
151            path_str.ends_with(".codex/auth.json") || path_str.ends_with(r".codex\auth.json"),
152            "expected path to end with .codex/auth.json, got {}",
153            path_str
154        );
155    }
156
157    #[test]
158    fn test_default_codex_auth_path_in_does_not_create_dir() {
159        // The inner helper must be a pure path-join with no filesystem side
160        // effects. Override the "home" directory with a fresh tempdir and
161        // assert the .codex subdirectory does NOT appear after the call.
162        let tmp = TempDir::new().expect("Should create tempdir");
163        let home = tmp.path();
164
165        let path = default_codex_auth_path_in(home);
166
167        assert_eq!(path, home.join(".codex").join("auth.json"));
168        assert!(
169            !home.join(".codex").exists(),
170            ".codex directory must not be created by default_codex_auth_path_in"
171        );
172    }
173}