//! Credential resolution without context exposure (`SMH-SPEC-SPEC0001`, //! Providers: authentication). //! //! Credentials come from environment variables or explicit values, resolve //! without any network access, and can be checked for readiness //! independently of a model request. Registered holders are redacted in //! diagnostics, and updating one provider's credential can never disturb //! another. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime}; use crate::models::ProviderKind; /// Where a credential comes from. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CredentialSource { /// Read from this environment variable at resolve time. Env(String), /// An explicit value, typically from configuration or a plugin. Explicit(String), } /// A resolved credential with optional renewal. /// /// Carries a renewal closure, so it deliberately implements neither /// [`std::fmt::Debug`] nor equality; use [`Credential::describe`] for /// diagnostics. #[derive(Clone)] pub struct Credential { source: CredentialSource, expires_at: Option, renewed: Option Option + Send + Sync>>, } impl Credential { /// A credential read from an environment variable when needed. #[must_use] pub fn from_env(var: impl Into) -> Self { Self { source: CredentialSource::Env(var.into()), expires_at: None, renewed: None, } } /// A credential with an explicit value. #[must_use] pub fn explicit(value: impl Into) -> Self { Self { source: CredentialSource::Explicit(value.into()), expires_at: None, renewed: None, } } /// Attach an expiry; expired values must renew before use. #[must_use] pub fn expires_in(mut self, after: Duration) -> Self { self.expires_at = SystemTime::now().checked_add(after); self } /// Attach an absolute expiry; the injectable form of [`Self::expires_in`]. #[must_use] pub const fn expires_at(mut self, at: SystemTime) -> Self { self.expires_at = Some(at); self } /// Attach a renewal closure that returns a fresh value. #[must_use] pub fn renew_with(mut self, renew: Arc Option + Send + Sync>) -> Self { self.renewed = Some(renew); self } fn is_expired(&self) -> bool { self.expires_at.is_some_and(|at| SystemTime::now() >= at) } /// The secret value, resolving lazily and renewing when expired. /// /// # Errors /// /// Returns a [`smith::error::ProviderFault::Authentication`] fault when /// the source is empty, the value expired, or renewal failed. pub fn secret(&self) -> smith::error::Result { if self.is_expired() { if let Some(renew) = &self.renewed && let Some(fresh) = renew() { return Ok(fresh); } return Err(auth("credential expired and renewal failed")); } match &self.source { CredentialSource::Env(var) => std::env::var(var) .ok() .filter(|value| !value.is_empty()) .ok_or_else(|| auth(format!("environment variable {var} is empty or unset"))), CredentialSource::Explicit(value) => Ok(value.clone()), } } /// Whether a request could be dispatched with this credential now, /// checked without any network access. #[must_use] pub fn is_ready(&self) -> bool { !self.is_expired() && match &self.source { CredentialSource::Env(var) => { std::env::var(var).is_ok_and(|value| !value.is_empty()) } CredentialSource::Explicit(_) => true, } } } /// Redacted description; the secret value never appears. impl Credential { /// A redacted description safe for logs and diagnostics. #[must_use] pub fn describe(&self) -> String { match &self.source { CredentialSource::Env(var) => format!("env:{var}"), CredentialSource::Explicit(_) => "explicit:".to_string(), } } } /// Per-provider credential registry; one owner, updates never touch /// unrelated providers. #[derive(Default)] pub struct AuthRegistry { credentials: HashMap, } impl AuthRegistry { /// An empty registry. #[must_use] pub fn new() -> Self { Self::default() } /// Register or replace the credential for one provider. pub fn set(&mut self, provider: impl Into, credential: Credential) { self.credentials.insert(provider.into(), credential); } /// The registered credential, when present. #[must_use] pub fn get(&self, provider: &str) -> Option<&Credential> { self.credentials.get(provider) } /// Whether the provider has a ready credential, without network access. #[must_use] pub fn is_ready(&self, provider: &str) -> bool { self.get(provider).is_some_and(Credential::is_ready) } } /// The environment variable carrying a provider's credential. #[must_use] pub const fn credential_env_var(kind: ProviderKind) -> &'static str { match kind { ProviderKind::OpenAiCompatible => "OPENAI_API_KEY", ProviderKind::Anthropic => "ANTHROPIC_API_KEY", ProviderKind::Google => "GEMINI_API_KEY", ProviderKind::Plugin => "SMITH_PLUGIN_API_KEY", } } /// The default endpoint base URL for a provider. #[must_use] pub const fn default_base_url(kind: ProviderKind) -> &'static str { match kind { ProviderKind::OpenAiCompatible => "https://api.openai.com/v1", ProviderKind::Anthropic => "https://api.anthropic.com", ProviderKind::Google => "https://generativelanguage.googleapis.com/v1beta", ProviderKind::Plugin => "https://localhost", } } /// Durable credential store under the Smith data directory. /// /// One plaintext file per provider, user-only permissions where the /// platform supports them, no keychain dependency. #[derive(Clone, Debug)] pub struct AuthStore { dir: PathBuf, } impl AuthStore { /// The store under the platform Smith data directory. #[must_use] pub fn at_default() -> Self { Self { dir: smith::config::auth_dir(), } } /// A store at an explicit directory, primarily for tests. #[must_use] pub fn at(dir: impl Into) -> Self { Self { dir: dir.into() } } /// The backing directory. #[must_use] pub fn dir(&self) -> &Path { &self.dir } fn path(&self, kind: ProviderKind) -> PathBuf { self.dir.join(kind.slug()) } /// Persist one provider credential with user-only permissions. /// /// # Errors /// /// Returns a [`smith::error::SmithError::Session`] fault when the store /// directory or file cannot be created or written. pub fn save(&self, kind: ProviderKind, secret: &str) -> smith::error::Result<()> { std::fs::create_dir_all(&self.dir).map_err(|e| store_io("create", &e))?; let path = self.path(kind); std::fs::write(&path, secret).map_err(|e| store_io("write", &e))?; restrict_permissions(&path); Ok(()) } /// The stored credential for a provider, when present. /// /// # Errors /// /// Returns a [`smith::error::SmithError::Session`] fault when the store /// cannot be read. pub fn load(&self, kind: ProviderKind) -> smith::error::Result> { match std::fs::read_to_string(self.path(kind)) { Ok(secret) => Ok(Some(secret)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(store_io("read", &error)), } } /// Delete one provider credential; reports whether an entry existed. /// /// # Errors /// /// Returns a [`smith::error::SmithError::Session`] fault when deletion /// fails for a reason other than absence. pub fn remove(&self, kind: ProviderKind) -> smith::error::Result { match std::fs::remove_file(self.path(kind)) { Ok(()) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(error) => Err(store_io("remove", &error)), } } /// The providers with a stored credential. /// /// # Errors /// /// Returns a [`smith::error::SmithError::Session`] fault when the store /// directory cannot be listed. pub fn list(&self) -> smith::error::Result> { let mut kinds = Vec::new(); let entries = match std::fs::read_dir(&self.dir) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(kinds), Err(error) => return Err(store_io("list", &error)), }; for entry in entries.flatten() { if let Some(kind) = ProviderKind::from_slug(&entry.file_name().to_string_lossy()) { kinds.push(kind); } } kinds.sort(); Ok(kinds) } } fn store_io(step: &str, error: &std::io::Error) -> smith::error::SmithError { smith::error::SmithError::Session { code: "AUTH_STORE".to_string(), message: format!("auth store {step} failed: {error}"), } } #[cfg(unix)] fn restrict_permissions(path: &Path) { use std::os::unix::fs::PermissionsExt; let permissions = std::fs::Permissions::from_mode(0o600); let _ = std::fs::set_permissions(path, permissions); } #[cfg(not(unix))] fn restrict_permissions(_path: &Path) {} fn auth(message: impl Into) -> smith::error::SmithError { smith::error::SmithError::Provider { fault: smith::error::ProviderFault::Authentication { message: message.into(), }, } } #[cfg(test)] mod tests { use super::*; #[test] fn explicit_credential_resolves_and_redacts() { let credential = Credential::explicit("sk-test"); assert!(credential.is_ready()); assert_eq!(credential.secret().unwrap_or_default(), "sk-test"); assert_eq!(credential.describe(), "explicit:"); } #[test] fn expired_credential_renews_or_fails_explicitly() { // inject: expiry is a fixed past instant, never a race against the clock. let past = SystemTime::UNIX_EPOCH; let credential = Credential::explicit("old") .expires_at(past) .renew_with(Arc::new(|| Some("fresh".to_string()))); assert_eq!(credential.secret().unwrap_or_default(), "fresh"); let live = Credential::explicit("old").expires_in(Duration::from_secs(3_600)); assert_eq!(live.secret().unwrap_or_default(), "old"); let dead = Credential::explicit("old").expires_at(past); assert_eq!( dead.secret().err().map(|e| e.code().to_string()), Some("PROVIDER_AUTH".to_string()) ); assert!(!dead.is_ready()); } #[test] fn env_credential_resolves_when_set_and_fails_when_unset() { // Cargo and nextest set CARGO_MANIFEST_DIR for the test process. let set = Credential::from_env("CARGO_MANIFEST_DIR"); assert!(set.is_ready()); assert_eq!(set.secret().unwrap_or_default(), env!("CARGO_MANIFEST_DIR")); let unset = Credential::from_env("SMITH_TEST_UNSET_CREDENTIAL_VAR"); assert!(!unset.is_ready()); assert_eq!( unset.secret().err().map(|e| e.code().to_string()), Some("PROVIDER_AUTH".to_string()) ); } #[test] fn unregistered_provider_is_not_ready() { let mut registry = AuthRegistry::new(); registry.set("anthropic", Credential::explicit("a")); assert!(!registry.is_ready("openai")); } #[test] fn credential_env_vars_are_exact() { for (kind, var) in [ (ProviderKind::OpenAiCompatible, "OPENAI_API_KEY"), (ProviderKind::Anthropic, "ANTHROPIC_API_KEY"), (ProviderKind::Google, "GEMINI_API_KEY"), (ProviderKind::Plugin, "SMITH_PLUGIN_API_KEY"), ] { assert_eq!(credential_env_var(kind), var); } } /// A fresh store directory per test process, removed before use. fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("smith_ai_{name}_{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); dir } #[test] fn store_round_trip_lists_saved_providers_and_forgets_removed_ones() { let dir = scratch("store_round_trip"); let store = AuthStore::at(&dir); assert_eq!(store.list().ok(), Some(vec![]), "missing dir lists empty"); assert_eq!(store.remove(ProviderKind::Google).ok(), Some(false)); assert_eq!(store.load(ProviderKind::Google).ok(), Some(None)); for (kind, secret) in [(ProviderKind::Google, "g"), (ProviderKind::Anthropic, "a")] { assert!(store.save(kind, secret).is_ok()); } assert_eq!( store.list().ok(), Some(vec![ProviderKind::Anthropic, ProviderKind::Google]) ); assert_eq!(store.remove(ProviderKind::Google).ok(), Some(true)); assert_eq!(store.remove(ProviderKind::Google).ok(), Some(false)); assert_eq!(store.list().ok(), Some(vec![ProviderKind::Anthropic])); assert_eq!(store.load(ProviderKind::Google).ok(), Some(None)); assert_eq!( store.load(ProviderKind::Anthropic).ok(), Some(Some("a".to_string())) ); let _ = std::fs::remove_dir_all(&dir); } #[test] fn store_io_failures_other_than_absence_surface() { let dir = scratch("store_io_failures"); // A directory where the provider file belongs: read and unlink fail. assert!(std::fs::create_dir_all(dir.join(ProviderKind::Plugin.slug())).is_ok()); let store = AuthStore::at(&dir); let code = |result: smith::error::Result<()>| result.err().map(|e| e.code().to_string()); let store_fault = Some("AUTH_STORE".to_string()); assert_eq!( code(store.load(ProviderKind::Plugin).map(drop)), store_fault ); assert_eq!( code(store.remove(ProviderKind::Plugin).map(drop)), store_fault ); // A file where the store directory belongs: listing fails. let file = dir.join("not-a-dir"); assert!(std::fs::write(&file, "").is_ok()); assert_eq!(code(AuthStore::at(&file).list().map(drop)), store_fault); let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] fn saved_credentials_are_user_only() { use std::os::unix::fs::PermissionsExt; let dir = scratch("store_permissions"); let store = AuthStore::at(&dir); assert!(store.save(ProviderKind::Anthropic, "a").is_ok()); let mode = std::fs::metadata(dir.join(ProviderKind::Anthropic.slug())) .map(|meta| meta.permissions().mode() & 0o777) .ok(); assert_eq!(mode, Some(0o600)); let _ = std::fs::remove_dir_all(&dir); } #[test] fn interleaved_updates_preserve_unrelated_providers() { let mut registry = AuthRegistry::new(); registry.set("anthropic", Credential::explicit("a")); registry.set("openai", Credential::explicit("o")); for round in 0..100 { registry.set("anthropic", Credential::explicit(format!("a{round}"))); assert!(registry.is_ready("openai")); assert!(registry.is_ready("anthropic")); } assert_eq!( registry.get("openai").map(Credential::describe), Some("explicit:".to_string()) ); } }