Skip to main content

alibi_core/utils/password/
mod.rs

1//! Password hashing, verification and validation.
2
3use crate::error::{AuthError, AuthResult};
4use crate::plugin::AuthContext;
5use crate::schema::AuthSchema;
6use crate::types::UpdateUser;
7use async_trait::async_trait;
8use serde::Serialize;
9use std::fmt::Write;
10use std::sync::Arc;
11use unicode_normalization::UnicodeNormalization;
12
13/// Custom password hasher trait for pluggable password hashing strategies.
14///
15/// When provided in plugin configs, this overrides the default scrypt-based
16/// password hashing.
17#[async_trait]
18pub trait PasswordHasher: Send + Sync {
19    /// Hash a plaintext password and return the hash string.
20    async fn hash(&self, password: &str) -> AuthResult<String>;
21    /// Verify a password against a hash string. Returns `true` if the password matches.
22    async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool>;
23}
24
25/// Logical identity of an actual endpoint call, independently of whether it
26/// came from HTTP. Trusted server APIs have a path and may have no request.
27#[derive(Clone, Debug)]
28pub struct PasswordHashContext {
29    pub path: Option<String>,
30    pub request: Option<crate::hooks::RequestHookContext>,
31}
32
33impl PasswordHashContext {
34    #[must_use]
35    pub fn from_request(request: crate::hooks::RequestHookContext) -> Self {
36        let path = request
37            .extensions
38            .get::<crate::plugin::ResolvedEndpoint>()
39            .map_or_else(|| request.path.clone(), |endpoint| endpoint.path.clone());
40        Self {
41            path: Some(path),
42            request: Some(request),
43        }
44    }
45}
46
47/// An initialized policy applied at actual password hashing, after endpoint
48/// admission and before the selected original hasher. Verification is unchanged.
49#[async_trait]
50pub trait PasswordHashHook: Send + Sync {
51    async fn before_hash(
52        &self,
53        password: &str,
54        context: Option<&PasswordHashContext>,
55    ) -> AuthResult<()>;
56}
57
58#[derive(Clone, Default)]
59pub(crate) struct PasswordHashHooks(pub Vec<Arc<dyn PasswordHashHook>>);
60
61/// The pinned Better Auth password format: hexadecimal salt and scrypt key.
62#[derive(Clone, Default, Debug)]
63pub struct ScryptHasher;
64
65#[async_trait]
66impl PasswordHasher for ScryptHasher {
67    async fn hash(&self, password: &str) -> AuthResult<String> {
68        let password = password.to_owned();
69        tokio::task::spawn_blocking(move || {
70            let mut salt = [0; 16];
71            rand::fill(&mut salt);
72            let salt = hexadecimal(&salt);
73            let key = derive_scrypt(&password, &salt)?;
74            Ok(format!("{salt}:{}", hexadecimal(&key)))
75        })
76        .await
77        .map_err(|error| AuthError::PasswordHash(error.to_string()))?
78    }
79
80    async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
81        let mut parts = hash.split(':');
82        let salt = parts
83            .next()
84            .filter(|salt| !salt.is_empty())
85            .ok_or_else(|| AuthError::PasswordHash("Invalid password hash".into()))?
86            .to_owned();
87        let expected = parts
88            .next()
89            .filter(|key| !key.is_empty())
90            .ok_or_else(|| AuthError::PasswordHash("Invalid password hash".into()))?
91            .to_owned();
92        let password = password.to_owned();
93        tokio::task::spawn_blocking(move || {
94            let actual = hexadecimal(&derive_scrypt(&password, &salt)?);
95            Ok(actual.len() == expected.len()
96                && actual
97                    .bytes()
98                    .zip(expected.bytes())
99                    .fold(0u8, |different, (left, right)| different | (left ^ right))
100                    == 0)
101        })
102        .await
103        .map_err(|error| AuthError::PasswordHash(error.to_string()))?
104    }
105}
106
107fn derive_scrypt(password: &str, salt: &str) -> AuthResult<[u8; 64]> {
108    let params = scrypt::Params::new(14, 16, 1)
109        .map_err(|error| AuthError::PasswordHash(error.to_string()))?;
110    let password = password.nfkc().collect::<String>();
111    let mut key = [0; 64];
112    // Upstream passes the hexadecimal salt STRING to scrypt, not its decoded bytes.
113    scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut key)
114        .map_err(|error| AuthError::PasswordHash(error.to_string()))?;
115    Ok(key)
116}
117
118fn hexadecimal(bytes: &[u8]) -> String {
119    bytes.iter().fold(String::new(), |mut output, byte| {
120        _ = write!(output, "{byte:02x}");
121        output
122    })
123}
124
125/// Hash `password` using the custom `hasher` (if provided) or the default
126/// scrypt algorithm and NFKC normalization.
127///
128/// # Errors
129///
130/// Propagates errors from the configured password hasher.
131pub async fn hash_password(
132    hasher: Option<&Arc<dyn PasswordHasher>>,
133    password: &str,
134) -> AuthResult<String> {
135    match hasher {
136        Some(hasher) => hasher.hash(password).await,
137        None => ScryptHasher.hash(password).await,
138    }
139}
140
141/// Verify `password` against `hash` using the custom `hasher` (if provided) or
142/// the default scrypt algorithm. Returns `Ok(())` on match, or
143/// `Err(AuthError::InvalidCredentials)` on mismatch.
144///
145/// # Errors
146///
147/// Propagates errors from the configured password hasher.
148pub async fn verify_password(
149    hasher: Option<&Arc<dyn PasswordHasher>>,
150    password: &str,
151    hash: &str,
152) -> AuthResult<()> {
153    let valid = match hasher {
154        Some(hasher) => hasher.verify(hash, password).await?,
155        None => ScryptHasher.verify(hash, password).await?,
156    };
157    if valid {
158        Ok(())
159    } else {
160        Err(AuthError::InvalidCredentials)
161    }
162}
163
164/// Validate `password` against both the plugin-level length limits and the global `PasswordConfig`
165/// strength rules.
166///
167/// Performs min-length, max-length, uppercase, lowercase, digit and special-character checks.
168///
169/// # Errors
170///
171/// Returns an error if the password is shorter or longer than the configured limits.
172pub fn validate_password(
173    password: &str,
174    min_length: usize,
175    max_length: usize,
176    ctx: &AuthContext<impl AuthSchema>,
177) -> AuthResult<()> {
178    let config = &ctx.config.password;
179
180    let length = password.encode_utf16().count();
181    if length < min_length {
182        return Err(AuthError::bad_request("Password too short"));
183    }
184
185    if length > max_length {
186        return Err(AuthError::bad_request("Password too long"));
187    }
188
189    if config.require_uppercase && !password.chars().any(char::is_uppercase) {
190        return Err(AuthError::bad_request(
191            "Password must contain at least one uppercase letter",
192        ));
193    }
194
195    if config.require_lowercase && !password.chars().any(char::is_lowercase) {
196        return Err(AuthError::bad_request(
197            "Password must contain at least one lowercase letter",
198        ));
199    }
200
201    if config.require_numbers && !password.chars().any(|c| c.is_ascii_digit()) {
202        return Err(AuthError::bad_request(
203            "Password must contain at least one number",
204        ));
205    }
206
207    if config.require_special
208        && !password
209            .chars()
210            .any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c))
211    {
212        return Err(AuthError::bad_request(
213            "Password must contain at least one special character",
214        ));
215    }
216
217    Ok(())
218}
219
220/// Serialize any `Serialize`-able value to `serde_json::Value`, converting
221/// errors to `AuthError::internal`.
222///
223/// # Errors
224///
225/// Returns an error if the value cannot be serialized as JSON.
226pub fn serialize_to_value(value: &impl Serialize) -> AuthResult<serde_json::Value> {
227    serde_json::to_value(value)
228        .map_err(|e| AuthError::internal(format!("Failed to serialize value: {e}")))
229}
230
231/// Build an `UpdateUser` that only changes the `metadata` field.
232#[must_use]
233pub fn update_user_metadata(metadata: serde_json::Value) -> UpdateUser {
234    UpdateUser {
235        metadata: Some(metadata),
236        ..Default::default()
237    }
238}
239
240// LCOV_EXCL_START
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[tokio::test]
246    async fn scrypt_verifies_pinned_runtime_vectors_and_normalizes_unicode() {
247        // Produced with pinned @better-auth/utils/password.node.mjs parameters.
248        let plain = "00112233445566778899aabbccddeeff:73122e887cfc14f91396cdef78dfe4b9dec28d459037601c4904cedba5a637ab97b39b40c236df5d42881d0109ca8cf0f85e39dbaba2911c190915bd8f30fe85";
249        let unicode = "00112233445566778899aabbccddeeff:d891432b268618420fb652f0515c0fcf5ae943c484f883e35d18a14c683ded87c0e469ab0cbdca84868fdb3680e7059018c0a6f90060eb0d866934dacfeae6a5";
250        assert!(verify_password(None, "password123", plain).await.is_ok());
251        assert!(matches!(
252            verify_password(None, "incorrect", plain).await,
253            Err(AuthError::InvalidCredentials)
254        ));
255        assert!(verify_password(None, "οΌ‘uth-Γ©-πŸ”’", unicode).await.is_ok());
256        assert!(
257            verify_password(None, "Auth-e\u{301}-πŸ”’", unicode)
258                .await
259                .is_ok()
260        );
261    }
262
263    #[tokio::test]
264    async fn scrypt_uses_random_hex_salt_and_rejects_malformed_or_changed_hashes() {
265        let first = hash_password(None, "password123").await.expect("hash");
266        let second = hash_password(None, "password123").await.expect("hash");
267        assert_ne!(first, second);
268        assert_eq!(first.len(), 32 + 1 + 128);
269        assert!(
270            first
271                .split(':')
272                .all(|part| part.bytes().all(|byte| byte.is_ascii_hexdigit()))
273        );
274        assert!(verify_password(None, "password123", &first).await.is_ok());
275        assert!(matches!(
276            verify_password(None, "password123", "bad").await,
277            Err(AuthError::PasswordHash(_))
278        ));
279        assert!(matches!(
280            verify_password(None, "password123", ":key").await,
281            Err(AuthError::PasswordHash(_))
282        ));
283        assert!(matches!(
284            verify_password(None, "password123", "salt:").await,
285            Err(AuthError::PasswordHash(_))
286        ));
287        let altered = format!("{first}0");
288        assert!(matches!(
289            verify_password(None, "password123", &altered).await,
290            Err(AuthError::InvalidCredentials)
291        ));
292    }
293
294    #[tokio::test]
295    async fn password_length_matches_utf16_code_units() {
296        let context = AuthContext::new(
297            Arc::new(crate::AuthConfig::new(
298                "password-tests-only-minimum-32-character-secret",
299            )),
300            crate::test_store::test_database().await,
301        );
302        assert!(validate_password("éééé", 8, 128, &context).is_err());
303        assert!(validate_password("πŸ”’πŸ”’πŸ”’πŸ”’", 8, 8, &context).is_ok());
304        assert!(validate_password("πŸ”’πŸ”’πŸ”’πŸ”’", 8, 7, &context).is_err());
305    }
306
307    #[tokio::test]
308    async fn test_password_validation() {
309        let mut config = crate::AuthConfig::new("test-secret");
310        config.password = crate::PasswordConfig {
311            require_uppercase: true,
312            require_lowercase: true,
313            require_numbers: true,
314            require_special: true,
315        };
316        let database = crate::test_store::test_database().await;
317        let ctx = AuthContext::new(Arc::new(config), database);
318
319        // Test valid password
320        assert!(validate_password("Password123!", 8, usize::MAX, &ctx).is_ok());
321
322        // Test too short
323        assert!(validate_password("Pass1!", 8, usize::MAX, &ctx).is_err());
324
325        // Test missing uppercase
326        assert!(validate_password("password123!", 8, usize::MAX, &ctx).is_err());
327
328        // Test missing lowercase
329        assert!(validate_password("PASSWORD123!", 8, usize::MAX, &ctx).is_err());
330
331        // Test missing number
332        assert!(validate_password("Password!", 8, usize::MAX, &ctx).is_err());
333
334        // Test missing special character
335        assert!(validate_password("Password123", 8, usize::MAX, &ctx).is_err());
336    }
337}
338// LCOV_EXCL_STOP