Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-ai/src/auth.rs

Raw
//! 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<SystemTime>,
    renewed: Option<Arc<dyn Fn() -> Option<String> + Send + Sync>>,
}

impl Credential {
    /// A credential read from an environment variable when needed.
    #[must_use]
    pub fn from_env(var: impl Into<String>) -> 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<String>) -> 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<dyn Fn() -> Option<String> + 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<String> {
        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:<redacted>".to_string(),
        }
    }
}

/// Per-provider credential registry; one owner, updates never touch
/// unrelated providers.
#[derive(Default)]
pub struct AuthRegistry {
    credentials: HashMap<String, Credential>,
}

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<String>, 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<PathBuf>) -> 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<Option<String>> {
        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<bool> {
        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<Vec<ProviderKind>> {
        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<String>) -> 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:<redacted>");
    }

    #[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:<redacted>".to_string())
        );
    }
}