use thiserror::Error;
use crate::config::ConfigResolver;
use crate::security::config_encryptor::{ConfigDecryptionError, ConfigEncryptor};
#[derive(Debug, Error)]
pub enum AuthenticationError {
#[error(
"Remote registry requires authentication. \
Set --api-key, APCORE_AUTH_API_KEY, or auth.api_key in config."
)]
MissingApiKey,
#[error("Authentication failed. Verify your API key.")]
InvalidApiKey,
#[error("Stored API key could not be decrypted: {0}. Re-store with `apcli config set auth.api_key`.")]
DecryptionFailed(#[from] ConfigDecryptionError),
#[error("Configured API key contains invalid characters (CR/LF). Re-store the key without trailing newlines.")]
MalformedApiKey,
#[error("keyring error: {0}")]
KeyringError(String),
#[error("authentication request failed: {0}")]
RequestError(String),
}
pub struct AuthProvider {
config: ConfigResolver,
encryptor: Option<ConfigEncryptor>,
}
impl AuthProvider {
pub fn new(config: ConfigResolver) -> Self {
Self {
config,
encryptor: None,
}
}
pub fn with_encryptor(config: ConfigResolver, encryptor: ConfigEncryptor) -> Self {
Self {
config,
encryptor: Some(encryptor),
}
}
pub fn get_api_key(&self) -> Result<Option<String>, AuthenticationError> {
let raw = match self.config.resolve(
"auth.api_key",
Some("--api-key"),
Some("APCORE_AUTH_API_KEY"),
) {
Some(r) => r,
None => return Ok(None),
};
if raw.starts_with("keyring:") || raw.starts_with("enc:") {
let decoded = match self.encryptor.as_ref() {
Some(enc) => enc.retrieve(&raw, "auth.api_key"),
None => ConfigEncryptor::new()?.retrieve(&raw, "auth.api_key"),
};
decoded.map(Some).map_err(AuthenticationError::from)
} else {
Ok(Some(raw))
}
}
pub fn authenticate_request(
&self,
mut headers: std::collections::HashMap<String, String>,
) -> Result<std::collections::HashMap<String, String>, AuthenticationError> {
let key = self
.get_api_key()?
.ok_or(AuthenticationError::MissingApiKey)?;
let trimmed = key.trim_end_matches(['\r', '\n']);
if trimmed.contains('\r') || trimmed.contains('\n') {
return Err(AuthenticationError::MalformedApiKey);
}
headers.insert("Authorization".to_string(), format!("Bearer {trimmed}"));
Ok(headers)
}
pub fn apply_to_reqwest(
&self,
builder: reqwest::RequestBuilder,
) -> Result<reqwest::RequestBuilder, AuthenticationError> {
let mut headers = self.authenticate_request(std::collections::HashMap::new())?;
let auth_value = headers
.remove("Authorization")
.expect("authenticate_request must insert Authorization");
Ok(builder.header("Authorization", auth_value))
}
pub fn check_status_code(&self, status: u16) -> Result<(), AuthenticationError> {
match status {
401 | 403 => Err(AuthenticationError::InvalidApiKey),
_ => Ok(()),
}
}
pub fn handle_response(
&self,
response: reqwest::Response,
) -> Result<reqwest::Response, AuthenticationError> {
self.check_status_code(response.status().as_u16())?;
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn make_resolver_with_key(key: &str) -> ConfigResolver {
let mut flags = std::collections::HashMap::new();
flags.insert("--api-key".to_string(), Some(key.to_string()));
ConfigResolver::new(Some(flags), None)
}
fn make_resolver_empty() -> ConfigResolver {
ConfigResolver::new(None, None)
}
#[test]
fn test_get_api_key_from_env_var() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "test-key-env") };
let provider = AuthProvider::new(make_resolver_empty());
let result = provider.get_api_key();
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
assert_eq!(result.unwrap(), Some("test-key-env".to_string()));
}
#[test]
fn test_get_api_key_none_when_not_configured() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
let provider = AuthProvider::new(make_resolver_empty());
let result = provider.get_api_key();
assert_eq!(result.unwrap(), None);
}
#[test]
fn test_get_api_key_plain_key_from_cli_flag() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
let provider = AuthProvider::new(make_resolver_with_key("my-plain-key"));
let result = provider.get_api_key();
assert_eq!(result.unwrap(), Some("my-plain-key".to_string()));
}
#[test]
fn test_authenticate_request_adds_bearer_header() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "abc123") };
let provider = AuthProvider::new(make_resolver_empty());
let result = provider.authenticate_request(std::collections::HashMap::new());
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
let headers = result.expect("authenticate_request must succeed");
assert_eq!(
headers.get("Authorization").map(|s| s.as_str()),
Some("Bearer abc123")
);
}
#[test]
fn test_authenticate_request_no_key_raises() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
let provider = AuthProvider::new(make_resolver_empty());
let result = provider.authenticate_request(std::collections::HashMap::new());
assert!(matches!(result, Err(AuthenticationError::MissingApiKey)));
}
#[test]
fn test_authenticate_request_strips_trailing_crlf() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "key-with-trailing-newline\n") };
let provider = AuthProvider::new(make_resolver_empty());
let result = provider.authenticate_request(std::collections::HashMap::new());
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
assert!(
result.is_ok(),
"trailing newline must be stripped before header assembly"
);
}
#[test]
fn test_authenticate_request_rejects_embedded_crlf() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var("APCORE_AUTH_API_KEY", "bad\nkey") };
let provider = AuthProvider::new(make_resolver_empty());
let result = provider.authenticate_request(std::collections::HashMap::new());
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
assert!(
matches!(result, Err(AuthenticationError::MalformedApiKey)),
"embedded CR/LF must surface as MalformedApiKey, got {result:?}"
);
}
#[test]
fn test_get_api_key_propagates_decryption_error() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { std::env::remove_var("APCORE_AUTH_API_KEY") };
let provider = AuthProvider::new(make_resolver_with_key("enc:!!!not-base64!!!"));
let result = provider.get_api_key();
assert!(
matches!(result, Err(AuthenticationError::DecryptionFailed(_))),
"corrupt encrypted key must surface DecryptionFailed, got {result:?}"
);
}
#[test]
fn test_handle_response_401_returns_invalid_api_key() {
let missing = AuthenticationError::MissingApiKey;
assert_eq!(
missing.to_string(),
"Remote registry requires authentication. \
Set --api-key, APCORE_AUTH_API_KEY, or auth.api_key in config."
);
let invalid = AuthenticationError::InvalidApiKey;
assert_eq!(
invalid.to_string(),
"Authentication failed. Verify your API key."
);
}
#[test]
fn test_handle_response_403_returns_invalid_api_key() {
let err = AuthenticationError::InvalidApiKey;
assert!(err.to_string().contains("Authentication failed"));
}
}