Skip to main content

socorro_cli/
auth.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5use crate::{Error, Result};
6
7const SERVICE_NAME: &str = "socorro-cli";
8const TOKEN_KEY: &str = "api-token";
9
10/// Environment variable pointing to a file containing the API token.
11/// Used for CI/headless environments where no system keychain is available.
12/// The file should be stored in a location that AI agents cannot read
13/// (e.g., outside the project directory, with restricted permissions).
14const TOKEN_PATH_ENV_VAR: &str = "SOCORRO_API_TOKEN_PATH";
15
16/// Retrieves the API token, checking sources in order:
17/// 1. System keychain (preferred for interactive use)
18/// 2. File at path specified by SOCORRO_API_TOKEN_PATH (for CI/headless environments)
19///
20/// Returns None if no token is found (does not print anything).
21pub fn get_token() -> Option<String> {
22    // Try system keychain first
23    if let Some(token) = get_from_keychain() {
24        return Some(token);
25    }
26
27    // Fallback for CI/headless environments without a keychain
28    get_from_token_file()
29}
30
31fn get_from_token_file() -> Option<String> {
32    let path = std::env::var(TOKEN_PATH_ENV_VAR).ok()?;
33    let content = std::fs::read_to_string(&path).ok()?;
34    let token = content.trim().to_string();
35    if token.is_empty() { None } else { Some(token) }
36}
37
38fn get_from_keychain() -> Option<String> {
39    match keyring::Entry::new(SERVICE_NAME, TOKEN_KEY) {
40        Ok(entry) => match entry.get_password() {
41            Ok(password) => Some(password),
42            Err(keyring::Error::NoEntry) => None,
43            Err(_) => None,
44        },
45        Err(_) => None,
46    }
47}
48
49/// Returns detailed status for debugging keychain issues.
50pub fn get_keychain_status() -> KeychainStatus {
51    match keyring::Entry::new(SERVICE_NAME, TOKEN_KEY) {
52        Ok(entry) => match entry.get_password() {
53            Ok(_) => KeychainStatus::HasToken,
54            Err(e) => {
55                // Show all errors for debugging
56                KeychainStatus::Error(format!("get_password failed: {:?}", e))
57            }
58        },
59        Err(e) => KeychainStatus::Error(format!("Entry::new failed: {:?}", e)),
60    }
61}
62
63#[derive(Debug)]
64pub enum KeychainStatus {
65    HasToken,
66    NoToken,
67    Error(String),
68}
69
70/// Stores the API token in the system keychain.
71pub fn store_token(token: &str) -> Result<()> {
72    let entry = keyring::Entry::new(SERVICE_NAME, TOKEN_KEY)
73        .map_err(|e| Error::Keyring(format!("Failed to create entry: {}", e)))?;
74
75    entry
76        .set_password(token)
77        .map_err(|e| Error::Keyring(format!("Failed to store: {}", e)))?;
78
79    // Verify with a fresh entry (same instance may cache)
80    let verify_entry = keyring::Entry::new(SERVICE_NAME, TOKEN_KEY)
81        .map_err(|e| Error::Keyring(format!("Failed to create verify entry: {}", e)))?;
82
83    match verify_entry.get_password() {
84        Ok(stored) if stored == token => Ok(()),
85        Ok(_) => Err(Error::Keyring("Token mismatch after storage".to_string())),
86        Err(e) => Err(Error::Keyring(format!(
87            "Storage appeared to succeed but verification failed: {}. \
88             This may be a Windows Credential Manager issue.",
89            e
90        ))),
91    }
92}
93
94/// Removes the API token from the system keychain.
95pub fn delete_token() -> Result<()> {
96    let entry =
97        keyring::Entry::new(SERVICE_NAME, TOKEN_KEY).map_err(|e| Error::Keyring(e.to_string()))?;
98    match entry.delete_credential() {
99        Ok(()) => Ok(()),
100        Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
101        Err(e) => Err(Error::Keyring(e.to_string())),
102    }
103}
104
105/// Returns true if a token is stored in the keychain.
106pub fn has_token() -> bool {
107    get_token().is_some()
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use serial_test::serial;
114
115    #[test]
116    #[serial]
117    fn test_get_from_token_file_reads_token() {
118        let dir = tempfile::tempdir().unwrap();
119        let token_path = dir.path().join("token");
120        std::fs::write(&token_path, "my_secret_token").unwrap();
121
122        // Set the env var and test
123        // SAFETY: tests using env vars are run serially via #[serial]
124        unsafe { std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap()) };
125        let result = get_from_token_file();
126        unsafe { std::env::remove_var(TOKEN_PATH_ENV_VAR) };
127
128        assert_eq!(result, Some("my_secret_token".to_string()));
129    }
130
131    #[test]
132    #[serial]
133    fn test_get_from_token_file_trims_whitespace() {
134        let dir = tempfile::tempdir().unwrap();
135        let token_path = dir.path().join("token");
136        std::fs::write(&token_path, "  my_token_with_whitespace  \n").unwrap();
137
138        // SAFETY: tests using env vars are run serially via #[serial]
139        unsafe { std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap()) };
140        let result = get_from_token_file();
141        unsafe { std::env::remove_var(TOKEN_PATH_ENV_VAR) };
142
143        assert_eq!(result, Some("my_token_with_whitespace".to_string()));
144    }
145
146    #[test]
147    #[serial]
148    fn test_get_from_token_file_returns_none_for_empty_file() {
149        let dir = tempfile::tempdir().unwrap();
150        let token_path = dir.path().join("token");
151        std::fs::write(&token_path, "").unwrap();
152
153        // SAFETY: tests using env vars are run serially via #[serial]
154        unsafe { std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap()) };
155        let result = get_from_token_file();
156        unsafe { std::env::remove_var(TOKEN_PATH_ENV_VAR) };
157
158        assert_eq!(result, None);
159    }
160
161    #[test]
162    #[serial]
163    fn test_get_from_token_file_returns_none_for_whitespace_only() {
164        let dir = tempfile::tempdir().unwrap();
165        let token_path = dir.path().join("token");
166        std::fs::write(&token_path, "   \n\t  ").unwrap();
167
168        // SAFETY: tests using env vars are run serially via #[serial]
169        unsafe { std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap()) };
170        let result = get_from_token_file();
171        unsafe { std::env::remove_var(TOKEN_PATH_ENV_VAR) };
172
173        assert_eq!(result, None);
174    }
175
176    #[test]
177    #[serial]
178    fn test_get_from_token_file_returns_none_for_missing_file() {
179        // SAFETY: tests using env vars are run serially via #[serial]
180        unsafe { std::env::set_var(TOKEN_PATH_ENV_VAR, "/nonexistent/path/to/token") };
181        let result = get_from_token_file();
182        unsafe { std::env::remove_var(TOKEN_PATH_ENV_VAR) };
183
184        assert_eq!(result, None);
185    }
186
187    #[test]
188    #[serial]
189    fn test_get_from_token_file_returns_none_when_env_not_set() {
190        // SAFETY: tests using env vars are run serially via #[serial]
191        unsafe { std::env::remove_var(TOKEN_PATH_ENV_VAR) };
192        let result = get_from_token_file();
193        assert_eq!(result, None);
194    }
195}