pub mod file;
pub mod keyring;
pub mod memory;
#[cfg(feature = "full-cli")]
pub mod org_choice;
#[cfg(feature = "full-cli")]
pub mod pkce;
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) {}
fn holds(&self) -> bool {
self.retrieve().is_ok()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
Keychain,
File,
}
impl Tier {
pub fn as_str(self) -> &'static str {
match self {
Tier::Keychain => "keychain",
Tier::File => "file",
}
}
}
pub fn retrieve_stored(
primary: &dyn CredentialStore,
fallback: &dyn CredentialStore,
) -> Option<(SecretString, Tier)> {
if let Ok(key) = primary.retrieve() {
return Some((key, Tier::Keychain));
}
fallback.retrieve().ok().map(|key| (key, Tier::File))
}
pub fn delete_all_credentials(
primary: &dyn CredentialStore,
fallback: &dyn CredentialStore,
) -> Vec<Tier> {
let mut cleared = Vec::new();
for (store, tier) in [(primary, Tier::Keychain), (fallback, Tier::File)] {
let held = store.holds();
match store.delete() {
Ok(()) if held => cleared.push(tier),
Ok(()) => {}
Err(e) => tracing::warn!(
code = e.code,
tier = tier.as_str(),
error = %e.message,
"could not delete the stored credential"
),
}
}
cleared
}
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);
}
#[test]
fn logout_clears_both_tiers_and_reports_them() {
let primary = InMemoryCredentialStore::new();
primary.store(SecretString::from("k".to_string())).unwrap();
let fallback = InMemoryCredentialStore::new();
fallback.store(SecretString::from("k".to_string())).unwrap();
assert_eq!(
delete_all_credentials(&primary, &fallback),
vec![Tier::Keychain, Tier::File]
);
assert!(primary.retrieve().is_err());
assert!(fallback.retrieve().is_err());
}
#[test]
fn logout_reports_only_the_tier_that_held_a_key() {
let primary = InMemoryCredentialStore::new();
let fallback = InMemoryCredentialStore::new();
fallback.store(SecretString::from("k".to_string())).unwrap();
assert_eq!(
delete_all_credentials(&primary, &fallback),
vec![Tier::File]
);
assert_eq!(
delete_all_credentials(&primary, &fallback),
Vec::<Tier>::new()
);
}
#[test]
fn logout_still_clears_the_file_when_the_keychain_fails() {
let fallback = InMemoryCredentialStore::new();
fallback.store(SecretString::from("k".to_string())).unwrap();
assert_eq!(
delete_all_credentials(&UnavailableStore, &fallback),
vec![Tier::File]
);
assert!(fallback.retrieve().is_err());
}
#[test]
fn logout_clears_a_credentials_file_it_cannot_decrypt() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("credentials.enc");
FileCredentialStore::new(path.clone(), "agt_old".into())
.store(SecretString::from("k".to_string()))
.unwrap();
let file = FileCredentialStore::new(path.clone(), "agt_new".into());
assert!(file.retrieve().is_err());
assert_eq!(
delete_all_credentials(&InMemoryCredentialStore::new(), &file),
vec![Tier::File]
);
assert!(!path.exists());
}
#[test]
fn logout_revokes_the_stored_key_by_tier_order() {
let primary = InMemoryCredentialStore::new();
let fallback = InMemoryCredentialStore::new();
assert!(retrieve_stored(&primary, &fallback).is_none());
fallback
.store(SecretString::from("file-key".to_string()))
.unwrap();
let (key, tier) = retrieve_stored(&primary, &fallback).unwrap();
assert_eq!((key.expose_secret(), tier), ("file-key", Tier::File));
primary
.store(SecretString::from("keychain-key".to_string()))
.unwrap();
let (key, tier) = retrieve_stored(&primary, &fallback).unwrap();
assert_eq!(
(key.expose_secret(), tier),
("keychain-key", Tier::Keychain)
);
}
}