secrets-auth-userpass 1.1.0

Argon2id username/password auth method for the secrets server.
Documentation
use async_trait::async_trait;
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use secrets_core::auth::{AuthError, AuthMethod, AuthOutcome, AuthResult, LoginRequest};
use secrets_core::storage::{StorageBackend, StorageEntry};
use serde::{Deserialize, Serialize};

const USER_PREFIX: &str = "auth/userpass/users/";
const DEFAULT_TTL_SECONDS: i64 = 3600;

#[derive(Debug, Serialize, Deserialize)]
struct UserRecord {
    password_hash: String,
    policies: Vec<String>,
}

pub struct UserPassAuth;

impl Default for UserPassAuth {
    fn default() -> Self {
        Self::new()
    }
}

impl UserPassAuth {
    pub fn new() -> Self {
        Self
    }

    pub fn hash_password(password: &str) -> AuthResult<String> {
        let salt = SaltString::generate(&mut OsRng);
        Argon2::default()
            .hash_password(password.as_bytes(), &salt)
            .map(|h| h.to_string())
            .map_err(|e| AuthError::Other(e.to_string()))
    }

    pub async fn user_exists(storage: &dyn StorageBackend, username: &str) -> AuthResult<bool> {
        Ok(storage.get(&format!("{USER_PREFIX}{username}")).await?.is_some())
    }

    pub async fn create_user(
        storage: &dyn StorageBackend,
        username: &str,
        password: &str,
        policies: Vec<String>,
    ) -> AuthResult<()> {
        let record = UserRecord {
            password_hash: Self::hash_password(password)?,
            policies,
        };
        let value = serde_json::to_vec(&record).map_err(|e| AuthError::Other(e.to_string()))?;
        storage
            .put(
                &format!("{USER_PREFIX}{username}"),
                StorageEntry {
                    value,
                    expires_at: None,
                },
            )
            .await?;
        Ok(())
    }
}

#[async_trait]
impl AuthMethod for UserPassAuth {
    async fn login(
        &self,
        storage: &dyn StorageBackend,
        request: LoginRequest,
    ) -> AuthResult<AuthOutcome> {
        let LoginRequest::UserPass { username, password } = request else {
            return Err(AuthError::InvalidRequest(
                "expected username/password".into(),
            ));
        };

        let entry = storage
            .get(&format!("{USER_PREFIX}{username}"))
            .await?
            .ok_or(AuthError::InvalidCredentials)?;
        let record: UserRecord = serde_json::from_slice(&entry.value)
            .map_err(|e| AuthError::Other(e.to_string()))?;

        let hash = PasswordHash::new(&record.password_hash)
            .map_err(|e| AuthError::Other(e.to_string()))?;
        Argon2::default()
            .verify_password(password.as_bytes(), &hash)
            .map_err(|_| AuthError::InvalidCredentials)?;

        Ok(AuthOutcome {
            policies: record.policies,
            display_name: username,
            ttl_seconds: Some(DEFAULT_TTL_SECONDS),
        })
    }
}