Skip to main content

alibi_core/utils/password/
mod.rs

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