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())
);
}
}