apcore_cli/security/
auth.rs1use thiserror::Error;
12
13use crate::config::ConfigResolver;
14use crate::security::config_encryptor::{ConfigDecryptionError, ConfigEncryptor};
15
16#[derive(Debug, Error)]
22pub enum AuthenticationError {
23 #[error(
25 "Remote registry requires authentication. \
26 Set --api-key, APCORE_AUTH_API_KEY, or auth.api_key in config."
27 )]
28 MissingApiKey,
29
30 #[error("Authentication failed. Verify your API key.")]
32 InvalidApiKey,
33
34 #[error("Stored API key could not be decrypted: {0}. Re-store with `apcli config set auth.api_key`.")]
38 DecryptionFailed(#[from] ConfigDecryptionError),
39
40 #[error("Configured API key contains invalid characters (CR/LF). Re-store the key without trailing newlines.")]
43 MalformedApiKey,
44
45 #[error("keyring error: {0}")]
47 KeyringError(String),
48
49 #[error("authentication request failed: {0}")]
51 RequestError(String),
52}
53
54pub struct AuthProvider {
70 config: ConfigResolver,
71 encryptor: Option<ConfigEncryptor>,
72}
73
74impl AuthProvider {
75 pub fn new(config: ConfigResolver) -> Self {
78 Self {
79 config,
80 encryptor: None,
81 }
82 }
83
84 pub fn with_encryptor(config: ConfigResolver, encryptor: ConfigEncryptor) -> Self {
87 Self {
88 config,
89 encryptor: Some(encryptor),
90 }
91 }
92
93 pub fn get_api_key(&self) -> Result<Option<String>, AuthenticationError> {
100 let raw = match self.config.resolve(
106 "auth.api_key",
107 Some("--api-key"),
108 Some("APCORE_AUTH_API_KEY"),
109 ) {
110 Some(r) => r,
111 None => return Ok(None),
112 };
113
114 if raw.starts_with("keyring:") || raw.starts_with("enc:") {
116 let decoded = match self.encryptor.as_ref() {
117 Some(enc) => enc.retrieve(&raw, "auth.api_key"),
118 None => ConfigEncryptor::new()?.retrieve(&raw, "auth.api_key"),
119 };
120 decoded.map(Some).map_err(AuthenticationError::from)
121 } else {
122 Ok(Some(raw))
123 }
124 }
125
126 pub fn authenticate_request(
138 &self,
139 mut headers: std::collections::HashMap<String, String>,
140 ) -> Result<std::collections::HashMap<String, String>, AuthenticationError> {
141 let key = self
142 .get_api_key()?
143 .ok_or(AuthenticationError::MissingApiKey)?;
144 let trimmed = key.trim_end_matches(['\r', '\n']);
145 if trimmed.contains('\r') || trimmed.contains('\n') {
146 return Err(AuthenticationError::MalformedApiKey);
147 }
148 headers.insert("Authorization".to_string(), format!("Bearer {trimmed}"));
149 Ok(headers)
150 }
151
152 pub fn apply_to_reqwest(
156 &self,
157 builder: reqwest::RequestBuilder,
158 ) -> Result<reqwest::RequestBuilder, AuthenticationError> {
159 let mut headers = self.authenticate_request(std::collections::HashMap::new())?;
160 let auth_value = headers
161 .remove("Authorization")
162 .expect("authenticate_request must insert Authorization");
163 Ok(builder.header("Authorization", auth_value))
164 }
165
166 pub fn check_status_code(&self, status: u16) -> Result<(), AuthenticationError> {
171 match status {
172 401 | 403 => Err(AuthenticationError::InvalidApiKey),
173 _ => Ok(()),
174 }
175 }
176
177 pub fn handle_response(
181 &self,
182 response: reqwest::Response,
183 ) -> Result<reqwest::Response, AuthenticationError> {
184 self.check_status_code(response.status().as_u16())?;
185 Ok(response)
186 }
187}
188
189#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::sync::Mutex;
197
198 static ENV_LOCK: Mutex<()> = Mutex::new(());
200
201 fn make_resolver_with_key(key: &str) -> ConfigResolver {
202 let mut flags = std::collections::HashMap::new();
204 flags.insert("--api-key".to_string(), Some(key.to_string()));
205 ConfigResolver::new(Some(flags), None)
206 }
207
208 fn make_resolver_empty() -> ConfigResolver {
209 ConfigResolver::new(None, None)
210 }
211
212 #[test]
213 fn test_get_api_key_from_env_var() {
214 let _guard = ENV_LOCK.lock().unwrap();
215 unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "test-key-env") };
217 let provider = AuthProvider::new(make_resolver_empty());
218 let result = provider.get_api_key();
219 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
220 assert_eq!(result.unwrap(), Some("test-key-env".to_string()));
221 }
222
223 #[test]
224 fn test_get_api_key_none_when_not_configured() {
225 let _guard = ENV_LOCK.lock().unwrap();
226 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
227 let provider = AuthProvider::new(make_resolver_empty());
228 let result = provider.get_api_key();
229 assert_eq!(result.unwrap(), None);
230 }
231
232 #[test]
233 fn test_get_api_key_plain_key_from_cli_flag() {
234 let _guard = ENV_LOCK.lock().unwrap();
235 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
236 let provider = AuthProvider::new(make_resolver_with_key("my-plain-key"));
237 let result = provider.get_api_key();
238 assert_eq!(result.unwrap(), Some("my-plain-key".to_string()));
239 }
240
241 #[test]
242 fn test_authenticate_request_adds_bearer_header() {
243 let _guard = ENV_LOCK.lock().unwrap();
244 unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "abc123") };
245 let provider = AuthProvider::new(make_resolver_empty());
246 let result = provider.authenticate_request(std::collections::HashMap::new());
247 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
248 let headers = result.expect("authenticate_request must succeed");
249 assert_eq!(
250 headers.get("Authorization").map(|s| s.as_str()),
251 Some("Bearer abc123")
252 );
253 }
254
255 #[test]
256 fn test_authenticate_request_no_key_raises() {
257 let _guard = ENV_LOCK.lock().unwrap();
258 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
259 let provider = AuthProvider::new(make_resolver_empty());
260 let result = provider.authenticate_request(std::collections::HashMap::new());
261 assert!(matches!(result, Err(AuthenticationError::MissingApiKey)));
262 }
263
264 #[test]
265 fn test_authenticate_request_strips_trailing_crlf() {
266 let _guard = ENV_LOCK.lock().unwrap();
267 unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "key-with-trailing-newline\n") };
268 let provider = AuthProvider::new(make_resolver_empty());
269 let result = provider.authenticate_request(std::collections::HashMap::new());
270 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
271 assert!(
272 result.is_ok(),
273 "trailing newline must be stripped before header assembly"
274 );
275 }
276
277 #[test]
278 fn test_authenticate_request_rejects_embedded_crlf() {
279 let _guard = ENV_LOCK.lock().unwrap();
280 unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "bad\nkey") };
281 let provider = AuthProvider::new(make_resolver_empty());
282 let result = provider.authenticate_request(std::collections::HashMap::new());
283 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
284 assert!(
285 matches!(result, Err(AuthenticationError::MalformedApiKey)),
286 "embedded CR/LF must surface as MalformedApiKey, got {result:?}"
287 );
288 }
289
290 #[test]
291 fn test_get_api_key_propagates_decryption_error() {
292 let _guard = ENV_LOCK.lock().unwrap();
297 unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
298 let provider = AuthProvider::new(make_resolver_with_key("enc:!!!not-base64!!!"));
299 let result = provider.get_api_key();
300 assert!(
301 matches!(result, Err(AuthenticationError::DecryptionFailed(_))),
302 "corrupt encrypted key must surface DecryptionFailed, got {result:?}"
303 );
304 }
305
306 #[test]
307 fn test_handle_response_401_returns_invalid_api_key() {
308 let missing = AuthenticationError::MissingApiKey;
316 assert_eq!(
317 missing.to_string(),
318 "Remote registry requires authentication. \
319 Set --api-key, APCORE_AUTH_API_KEY, or auth.api_key in config."
320 );
321
322 let invalid = AuthenticationError::InvalidApiKey;
323 assert_eq!(
324 invalid.to_string(),
325 "Authentication failed. Verify your API key."
326 );
327 }
328
329 #[test]
330 fn test_handle_response_403_returns_invalid_api_key() {
331 let err = AuthenticationError::InvalidApiKey;
336 assert!(err.to_string().contains("Authentication failed"));
337 }
338}