entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Credential storage trait and in-memory implementation.
//!
//! Defines the [`CredentialStore`] trait for pluggable credential backends
//! and provides [`InMemoryCredentialStore`] for testing and prototyping.

use std::collections::HashMap;
use std::fmt;

use crate::local::password::PasswordHash;

// ---------------------------------------------------------------------------
// Stored Credential
// ---------------------------------------------------------------------------

/// A credential record as stored in the backend.
///
/// Contains the username (for identification) and the password hash
/// (never the raw password).
#[derive(Debug, Clone)]
pub struct StoredCredential {
    /// The username associated with this credential.
    username: String,
    /// The password hash derived from the user's password.
    password_hash: PasswordHash,
}

impl StoredCredential {
    /// Creates a new `StoredCredential` with the given username and password hash.
    #[must_use]
    pub fn new(username: String, password_hash: PasswordHash) -> Self {
        Self {
            username,
            password_hash,
        }
    }

    /// Returns the username associated with this credential.
    #[must_use]
    #[inline]
    pub fn username(&self) -> &str {
        &self.username
    }

    /// Returns the password hash.
    #[must_use]
    #[inline]
    pub fn password_hash(&self) -> &PasswordHash {
        &self.password_hash
    }
}

// ---------------------------------------------------------------------------
// Credential Store Trait
// ---------------------------------------------------------------------------

/// Trait for credential storage backends.
///
/// Implementations provide lookup, store, and remove operations for
/// username/password credentials. The trait is generic over its error
/// type to accommodate diverse backend failure modes.
pub trait CredentialStore {
    /// Error type returned by storage operations.
    type Error: std::error::Error;

    /// Looks up a credential by username.
    ///
    /// Returns `Ok(Some(credential))` if found, `Ok(None)` if the username
    /// does not exist, or `Err` if the storage backend fails.
    ///
    /// # Errors
    ///
    /// Returns an error if the storage backend fails.
    fn lookup(&self, username: &str) -> Result<Option<StoredCredential>, Self::Error>;

    /// Stores a credential, overwriting any existing entry for the username.
    ///
    /// # Errors
    ///
    /// Returns an error if the storage backend fails.
    fn store(&mut self, username: &str, hash: PasswordHash) -> Result<(), Self::Error>;

    /// Removes a credential by username.
    ///
    /// Returns `Ok(true)` if the credential was found and removed, or
    /// `Ok(false)` if the username did not exist.
    ///
    /// # Errors
    ///
    /// Returns an error if the storage backend fails.
    fn remove(&mut self, username: &str) -> Result<bool, Self::Error>;
}

// ---------------------------------------------------------------------------
// In-Memory Store
// ---------------------------------------------------------------------------

/// A `HashMap`-backed credential store for testing and prototyping.
///
/// Not suitable for production use — credentials are stored in process
/// memory with no persistence, encryption, or access control.
#[derive(Debug, Default)]
pub struct InMemoryCredentialStore {
    credentials: HashMap<String, PasswordHash>,
}

impl InMemoryCredentialStore {
    /// Creates an empty in-memory credential store.
    #[must_use]
    pub fn new() -> Self {
        Self {
            credentials: HashMap::new(),
        }
    }
}

/// Error type for the in-memory store.
///
/// This store never produces errors in practice, but the trait requires a
/// named error type. [`core::convert::Infallible`] would technically work
/// (it implements both `Display` and `std::error::Error`), but exposing it
/// on the public surface reads poorly and ties callers to a std type, so we
/// use a trivial, named error instead.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InMemoryStoreError {
    _private: (),
}

impl fmt::Display for InMemoryStoreError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "in-memory store: unexpected error")
    }
}

impl std::error::Error for InMemoryStoreError {}

impl CredentialStore for InMemoryCredentialStore {
    type Error = InMemoryStoreError;

    fn lookup(&self, username: &str) -> Result<Option<StoredCredential>, Self::Error> {
        Ok(self
            .credentials
            .get(username)
            .map(|hash| StoredCredential::new(username.to_owned(), hash.clone())))
    }

    fn store(&mut self, username: &str, hash: PasswordHash) -> Result<(), Self::Error> {
        self.credentials.insert(username.to_owned(), hash);
        Ok(())
    }

    fn remove(&mut self, username: &str) -> Result<bool, Self::Error> {
        Ok(self.credentials.remove(username).is_some())
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::local::password::{MIN_MEMORY_KIB, PasswordConfig};

    /// Test-only password config with minimum parameters for speed.
    fn test_config() -> PasswordConfig {
        PasswordConfig::new()
            .with_memory_kib(MIN_MEMORY_KIB)
            .with_iterations(1)
            .with_parallelism(1)
    }

    // --- Store CRUD ---

    #[test]
    fn store_and_lookup() {
        let mut store = InMemoryCredentialStore::new();
        let config = test_config();
        let hash = PasswordHash::generate(b"password", &config).unwrap();

        store.store("alice", hash.clone()).unwrap();

        let cred = store.lookup("alice").unwrap().unwrap();
        assert_eq!(cred.username(), "alice");
        assert_eq!(cred.password_hash().to_phc_string(), hash.to_phc_string());
    }

    #[test]
    fn stored_credential_constructor_and_accessors() {
        let config = test_config();
        let hash = PasswordHash::generate(b"password", &config).unwrap();
        let cred = StoredCredential::new("alice".to_owned(), hash.clone());
        assert_eq!(cred.username(), "alice");
        assert_eq!(cred.password_hash().to_phc_string(), hash.to_phc_string());
    }

    #[test]
    fn lookup_nonexistent_returns_none() {
        let store = InMemoryCredentialStore::new();
        assert!(store.lookup("nobody").unwrap().is_none());
    }

    #[test]
    fn store_overwrites_existing() {
        let mut store = InMemoryCredentialStore::new();
        let config = test_config();
        let hash1 = PasswordHash::generate(b"password1", &config).unwrap();
        let hash2 = PasswordHash::generate(b"password2", &config).unwrap();

        store.store("alice", hash1).unwrap();
        store.store("alice", hash2.clone()).unwrap();

        let cred = store.lookup("alice").unwrap().unwrap();
        assert_eq!(cred.password_hash().to_phc_string(), hash2.to_phc_string());
    }

    #[test]
    fn remove_existing() {
        let mut store = InMemoryCredentialStore::new();
        let config = test_config();
        let hash = PasswordHash::generate(b"password", &config).unwrap();

        store.store("alice", hash).unwrap();
        assert!(store.remove("alice").unwrap());
        assert!(store.lookup("alice").unwrap().is_none());
    }

    #[test]
    fn remove_nonexistent_returns_false() {
        let mut store = InMemoryCredentialStore::new();
        assert!(!store.remove("nobody").unwrap());
    }

    #[test]
    fn store_multiple_users() {
        let mut store = InMemoryCredentialStore::new();
        let config = test_config();

        let hash_a = PasswordHash::generate(b"pass-a", &config).unwrap();
        let hash_b = PasswordHash::generate(b"pass-b", &config).unwrap();

        store.store("alice", hash_a.clone()).unwrap();
        store.store("bob", hash_b.clone()).unwrap();

        let cred_a = store.lookup("alice").unwrap().unwrap();
        let cred_b = store.lookup("bob").unwrap().unwrap();
        assert_eq!(
            cred_a.password_hash().to_phc_string(),
            hash_a.to_phc_string()
        );
        assert_eq!(
            cred_b.password_hash().to_phc_string(),
            hash_b.to_phc_string()
        );
    }

    // --- Verify Through Store ---

    #[test]
    fn stored_hash_verifies_password() {
        let mut store = InMemoryCredentialStore::new();
        let config = test_config();
        let hash = PasswordHash::generate(b"my-secret", &config).unwrap();

        store.store("alice", hash).unwrap();

        let cred = store.lookup("alice").unwrap().unwrap();
        assert!(cred.password_hash().verify(b"my-secret"));
        assert!(!cred.password_hash().verify(b"wrong"));
    }

    // --- Default ---

    #[test]
    fn default_store_is_empty() {
        let store = InMemoryCredentialStore::default();
        assert!(store.lookup("anyone").unwrap().is_none());
    }
}