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() {
36        None
37    } else {
38        Some(token)
39    }
40}
41
42fn get_from_keychain() -> Option<String> {
43    match keyring::Entry::new(SERVICE_NAME, TOKEN_KEY) {
44        Ok(entry) => match entry.get_password() {
45            Ok(password) => Some(password),
46            Err(keyring::Error::NoEntry) => None,
47            Err(_) => None,
48        },
49        Err(_) => None,
50    }
51}
52
53/// Returns detailed status for debugging keychain issues.
54pub fn get_keychain_status() -> KeychainStatus {
55    match keyring::Entry::new(SERVICE_NAME, TOKEN_KEY) {
56        Ok(entry) => match entry.get_password() {
57            Ok(_) => KeychainStatus::HasToken,
58            Err(e) => {
59                // Show all errors for debugging
60                KeychainStatus::Error(format!("get_password failed: {:?}", e))
61            }
62        },
63        Err(e) => KeychainStatus::Error(format!("Entry::new failed: {:?}", e)),
64    }
65}
66
67#[derive(Debug)]
68pub enum KeychainStatus {
69    HasToken,
70    NoToken,
71    Error(String),
72}
73
74/// Stores the API token in the system keychain.
75pub fn store_token(token: &str) -> Result<()> {
76    let entry = keyring::Entry::new(SERVICE_NAME, TOKEN_KEY)
77        .map_err(|e| Error::Keyring(format!("Failed to create entry: {}", e)))?;
78
79    entry
80        .set_password(token)
81        .map_err(|e| Error::Keyring(format!("Failed to store: {}", e)))?;
82
83    // Verify with a fresh entry (same instance may cache)
84    let verify_entry = keyring::Entry::new(SERVICE_NAME, TOKEN_KEY)
85        .map_err(|e| Error::Keyring(format!("Failed to create verify entry: {}", e)))?;
86
87    match verify_entry.get_password() {
88        Ok(stored) if stored == token => Ok(()),
89        Ok(_) => Err(Error::Keyring("Token mismatch after storage".to_string())),
90        Err(e) => Err(Error::Keyring(format!(
91            "Storage appeared to succeed but verification failed: {}. \
92             This may be a Windows Credential Manager issue.",
93            e
94        ))),
95    }
96}
97
98/// Removes the API token from the system keychain.
99pub fn delete_token() -> Result<()> {
100    let entry =
101        keyring::Entry::new(SERVICE_NAME, TOKEN_KEY).map_err(|e| Error::Keyring(e.to_string()))?;
102    match entry.delete_credential() {
103        Ok(()) => Ok(()),
104        Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
105        Err(e) => Err(Error::Keyring(e.to_string())),
106    }
107}
108
109/// Returns true if a token is stored in the keychain.
110pub fn has_token() -> bool {
111    get_token().is_some()
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use serial_test::serial;
118
119    #[test]
120    #[serial]
121    fn test_get_from_token_file_reads_token() {
122        let dir = tempfile::tempdir().unwrap();
123        let token_path = dir.path().join("token");
124        std::fs::write(&token_path, "my_secret_token").unwrap();
125
126        // Set the env var and test
127        std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap());
128        let result = get_from_token_file();
129        std::env::remove_var(TOKEN_PATH_ENV_VAR);
130
131        assert_eq!(result, Some("my_secret_token".to_string()));
132    }
133
134    #[test]
135    #[serial]
136    fn test_get_from_token_file_trims_whitespace() {
137        let dir = tempfile::tempdir().unwrap();
138        let token_path = dir.path().join("token");
139        std::fs::write(&token_path, "  my_token_with_whitespace  \n").unwrap();
140
141        std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap());
142        let result = get_from_token_file();
143        std::env::remove_var(TOKEN_PATH_ENV_VAR);
144
145        assert_eq!(result, Some("my_token_with_whitespace".to_string()));
146    }
147
148    #[test]
149    #[serial]
150    fn test_get_from_token_file_returns_none_for_empty_file() {
151        let dir = tempfile::tempdir().unwrap();
152        let token_path = dir.path().join("token");
153        std::fs::write(&token_path, "").unwrap();
154
155        std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap());
156        let result = get_from_token_file();
157        std::env::remove_var(TOKEN_PATH_ENV_VAR);
158
159        assert_eq!(result, None);
160    }
161
162    #[test]
163    #[serial]
164    fn test_get_from_token_file_returns_none_for_whitespace_only() {
165        let dir = tempfile::tempdir().unwrap();
166        let token_path = dir.path().join("token");
167        std::fs::write(&token_path, "   \n\t  ").unwrap();
168
169        std::env::set_var(TOKEN_PATH_ENV_VAR, token_path.to_str().unwrap());
170        let result = get_from_token_file();
171        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        std::env::set_var(TOKEN_PATH_ENV_VAR, "/nonexistent/path/to/token");
180        let result = get_from_token_file();
181        std::env::remove_var(TOKEN_PATH_ENV_VAR);
182
183        assert_eq!(result, None);
184    }
185
186    #[test]
187    #[serial]
188    fn test_get_from_token_file_returns_none_when_env_not_set() {
189        std::env::remove_var(TOKEN_PATH_ENV_VAR);
190        let result = get_from_token_file();
191        assert_eq!(result, None);
192    }
193}