lenso-module-auth-password 0.1.4

First-party password auth module for the Lenso backend framework.
Documentation
use crate::config::AuthPasswordConfig;
use argon2::password_hash::{PasswordHash, SaltString};
use argon2::{Argon2, Params, PasswordHasher, PasswordVerifier, Version};
use platform_core::error::ErrorDetail;
use platform_core::{AppError, AppResult, ErrorCode};
use std::fmt::Write as _;

const MAX_IDENTIFIER_BYTES: usize = 512;
const MIN_PASSWORD_BYTES: usize = 8;
const MAX_PASSWORD_BYTES: usize = 1024;

pub fn normalize_identifier(identifier: &str) -> AppResult<String> {
    let trimmed = identifier.trim();
    if trimmed.is_empty() || trimmed.len() > MAX_IDENTIFIER_BYTES {
        return Err(validation_error("identifier", "identifier is invalid"));
    }

    if trimmed.contains('@') {
        Ok(trimmed.to_ascii_lowercase())
    } else {
        Ok(trimmed.to_owned())
    }
}

pub fn validate_password(password: &str) -> AppResult<()> {
    if password.len() < MIN_PASSWORD_BYTES || password.len() > MAX_PASSWORD_BYTES {
        return Err(validation_error("password", "password is invalid"));
    }
    Ok(())
}

pub fn hash_password(password: &str, config: &AuthPasswordConfig) -> AppResult<String> {
    let mut salt_bytes = [0u8; 16];
    getrandom::fill(&mut salt_bytes).expect("OS randomness should be available");
    let salt = SaltString::encode_b64(&salt_bytes)
        .expect("16 random bytes should produce a valid Argon2 salt");
    argon2_from_config(config)?
        .hash_password(password.as_bytes(), &salt)
        .map(|hash| hash.to_string())
        .map_err(|_| AppError::new(ErrorCode::Internal, "Password hashing failed"))
}

pub fn verify_password(password_hash: &str, password: &str) -> AppResult<bool> {
    let parsed = PasswordHash::new(password_hash).map_err(|source| {
        AppError::new(
            ErrorCode::Internal,
            format!("Stored password hash is invalid: {source}"),
        )
    })?;

    Ok(Argon2::default()
        .verify_password(password.as_bytes(), &parsed)
        .is_ok())
}

pub fn new_session_token() -> String {
    let mut bytes = [0u8; 32];
    getrandom::fill(&mut bytes).expect("OS randomness should be available");

    let mut token = String::with_capacity("sess_".len() + bytes.len() * 2);
    token.push_str("sess_");
    for byte in bytes {
        let _ = write!(token, "{byte:02x}");
    }
    token
}

fn argon2_from_config(config: &AuthPasswordConfig) -> AppResult<Argon2<'static>> {
    let params = Params::new(
        config.argon2_memory_kib,
        config.argon2_time_cost,
        config.argon2_parallelism,
        None,
    )
    .map_err(|_| AppError::new(ErrorCode::Internal, "Invalid password hash configuration"))?;

    Ok(Argon2::new(
        config.argon2_algorithm(),
        Version::default(),
        params,
    ))
}

fn validation_error(field: &str, reason: &str) -> AppError {
    AppError::validation(
        "Request validation failed",
        vec![ErrorDetail {
            field: Some(field.to_owned()),
            reason: reason.to_owned(),
        }],
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn email_identifiers_are_trimmed_and_lowercased() {
        assert_eq!(
            normalize_identifier("  Ada@Example.COM  ").expect("identifier should normalize"),
            "ada@example.com"
        );
    }

    #[test]
    fn phone_like_identifiers_are_trimmed_without_lowercasing() {
        assert_eq!(
            normalize_identifier("  +8613800000000  ").expect("identifier should normalize"),
            "+8613800000000"
        );
    }

    #[test]
    fn password_hash_verifies_original_password_only() {
        let hash = hash_password("correct horse", &AuthPasswordConfig::default())
            .expect("password should hash");

        assert!(verify_password(&hash, "correct horse").expect("hash should verify"));
        assert!(!verify_password(&hash, "wrong horse").expect("hash should verify"));
    }

    #[test]
    fn password_hash_uses_configured_argon2_policy() {
        let config = AuthPasswordConfig {
            hash_algorithm: crate::config::HashAlgorithm::Argon2i,
            argon2_memory_kib: 16 * 1024,
            argon2_time_cost: 3,
            argon2_parallelism: 2,
            ..AuthPasswordConfig::default()
        };
        let hash = hash_password("correct horse", &config).expect("password should hash");
        let parsed = PasswordHash::new(&hash).expect("hash should parse");

        assert_eq!(parsed.algorithm.to_string(), "argon2i");
        assert_eq!(
            parsed
                .params
                .get("m")
                .and_then(|value| value.decimal().ok()),
            Some(16 * 1024)
        );
        assert_eq!(
            parsed
                .params
                .get("t")
                .and_then(|value| value.decimal().ok()),
            Some(3)
        );
        assert_eq!(
            parsed
                .params
                .get("p")
                .and_then(|value| value.decimal().ok()),
            Some(2)
        );
    }

    #[test]
    fn session_tokens_are_random_enough_for_auth() {
        let first = new_session_token();
        let second = new_session_token();

        assert!(first.starts_with("sess_"));
        assert_eq!(first.len(), "sess_".len() + 64);
        assert_ne!(first, second);
    }
}