pub mod file;
pub mod keyring;
pub mod memory;
use secrecy::SecretString;
use crate::error::OlError;
pub trait CredentialStore: Send + Sync {
fn store(&self, key: SecretString) -> Result<(), OlError>;
fn retrieve(&self) -> Result<SecretString, OlError>;
fn delete(&self) -> Result<(), OlError>;
}
pub(crate) use crate::error::{
ERR_FILE_FALLBACK_ERROR, ERR_KEYCHAIN_PERMISSION, ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS,
};
pub fn retrieve_credential(
primary: &dyn CredentialStore,
fallback: &dyn CredentialStore,
) -> Result<SecretString, OlError> {
if let Ok(key) = primary.retrieve() {
return Ok(key);
}
if let Ok(val) = std::env::var("OPENLATCH_API_KEY") {
if !val.is_empty() {
return Ok(SecretString::from(val));
}
}
if let Ok(key) = fallback.retrieve() {
return Ok(key);
}
Err(OlError::new(
ERR_NO_CREDENTIALS,
"No API key found in keychain, OPENLATCH_API_KEY env var, or encrypted file",
)
.with_suggestion("Run 'openlatch auth login' to authenticate, or set OPENLATCH_API_KEY."))
}
pub use self::file::FileCredentialStore;
pub use self::keyring::KeyringCredentialStore;
pub use self::memory::InMemoryCredentialStore;
pub struct FallbackCredentialStore {
primary: Box<dyn CredentialStore>,
fallback: Box<dyn CredentialStore>,
}
impl FallbackCredentialStore {
pub fn new(primary: Box<dyn CredentialStore>, fallback: Box<dyn CredentialStore>) -> Self {
Self { primary, fallback }
}
}
impl CredentialStore for FallbackCredentialStore {
fn store(&self, key: SecretString) -> Result<(), OlError> {
self.primary.store(key)
}
fn retrieve(&self) -> Result<SecretString, OlError> {
retrieve_credential(self.primary.as_ref(), self.fallback.as_ref())
}
fn delete(&self) -> Result<(), OlError> {
self.primary.delete()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::auth::memory::InMemoryCredentialStore;
use secrecy::ExposeSecret;
#[test]
fn test_retrieve_credential_uses_primary_first() {
let primary = InMemoryCredentialStore::new();
primary
.store(SecretString::from("primary-key".to_string()))
.unwrap();
let fallback = InMemoryCredentialStore::new();
fallback
.store(SecretString::from("fallback-key".to_string()))
.unwrap();
let result = retrieve_credential(&primary, &fallback).unwrap();
assert_eq!(result.expose_secret(), "primary-key");
}
#[test]
fn test_retrieve_credential_falls_through_to_fallback_when_primary_empty() {
let primary = InMemoryCredentialStore::new();
let fallback = InMemoryCredentialStore::new();
fallback
.store(SecretString::from("fallback-key".to_string()))
.unwrap();
let result = retrieve_credential(&primary, &fallback).unwrap();
assert_eq!(result.expose_secret(), "fallback-key");
}
#[test]
fn test_retrieve_credential_returns_err_when_all_empty() {
let primary = InMemoryCredentialStore::new();
let fallback = InMemoryCredentialStore::new();
let result = retrieve_credential(&primary, &fallback);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, ERR_NO_CREDENTIALS);
}
#[test]
fn test_secret_string_debug_does_not_leak_value() {
let secret = SecretString::from("my-api-key".to_string());
let debug_output = format!("{:?}", secret);
assert!(
!debug_output.contains("my-api-key"),
"SecretString Debug output must not contain the actual secret: {debug_output}"
);
}
#[test]
fn test_retrieve_credential_env_var_override() {
let primary = InMemoryCredentialStore::new();
let fallback = InMemoryCredentialStore::new();
let result = retrieve_credential(&primary, &fallback);
assert!(result.is_err());
assert_eq!(result.unwrap_err().code, ERR_NO_CREDENTIALS);
}
}