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>;
fn invalidate(&self) {}
}
pub(crate) use crate::error::{
ERR_FILE_FALLBACK_ERROR, ERR_KEYCHAIN_PERMISSION, ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS,
};
pub fn store_credential(
primary: &dyn CredentialStore,
fallback: &dyn CredentialStore,
key: SecretString,
) -> Result<(), OlError> {
let Err(primary_err) = primary.store(key.clone()) else {
return Ok(());
};
fallback.store(key).map_err(|fallback_err| {
OlError::new(
fallback_err.code,
format!(
"could not store the credential in the OS keychain ({}) and the \
encrypted file fallback also failed: {}",
primary_err.message, fallback_err.message
),
)
})
}
pub fn retrieve_credential(
primary: &dyn CredentialStore,
fallback: &dyn CredentialStore,
) -> Result<SecretString, OlError> {
if let Ok(val) = std::env::var("OPENLATCH_API_KEY") {
if !val.is_empty() {
return Ok(SecretString::from(val));
}
}
if let Ok(key) = primary.retrieve() {
return Ok(key);
}
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 system 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> {
store_credential(self.primary.as_ref(), self.fallback.as_ref(), 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()
}
fn invalidate(&self) {
self.primary.invalidate();
self.fallback.invalidate();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::auth::memory::InMemoryCredentialStore;
use secrecy::ExposeSecret;
struct UnavailableStore;
impl CredentialStore for UnavailableStore {
fn store(&self, _key: SecretString) -> Result<(), OlError> {
Err(OlError::new("OL-TEST", "OS keychain is not available"))
}
fn retrieve(&self) -> Result<SecretString, OlError> {
Err(OlError::new("OL-TEST", "OS keychain is not available"))
}
fn delete(&self) -> Result<(), OlError> {
Err(OlError::new("OL-TEST", "OS keychain is not available"))
}
}
#[test]
fn a_credential_falls_back_to_the_file_when_the_keychain_cannot_take_it() {
let fallback = InMemoryCredentialStore::new();
store_credential(
&UnavailableStore,
&fallback,
SecretString::from("k-123".to_string()),
)
.expect("an unavailable keychain must not fail the whole flow");
assert_eq!(
retrieve_credential(&UnavailableStore, &fallback)
.expect("stored")
.expose_secret(),
"k-123"
);
}
#[test]
fn both_stores_failing_is_reported() {
let err = store_credential(
&UnavailableStore,
&UnavailableStore,
SecretString::from("k".to_string()),
)
.expect_err("both tiers failed");
assert!(
err.message.contains("keychain") && err.message.contains("fallback also failed"),
"the message must name both tiers: {}",
err.message
);
}
#[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]
#[ignore] fn test_env_var_beats_both_stores() {
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();
std::env::set_var("OPENLATCH_API_KEY", "env-key");
let result = retrieve_credential(&primary, &fallback).unwrap();
std::env::remove_var("OPENLATCH_API_KEY");
assert_eq!(result.expose_secret(), "env-key");
}
#[test]
#[ignore] fn test_empty_env_var_falls_through_to_stores() {
let primary = InMemoryCredentialStore::new();
primary
.store(SecretString::from("primary-key".to_string()))
.unwrap();
let fallback = InMemoryCredentialStore::new();
std::env::set_var("OPENLATCH_API_KEY", "");
let result = retrieve_credential(&primary, &fallback).unwrap();
std::env::remove_var("OPENLATCH_API_KEY");
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_absent_falls_through() {
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);
}
}