Skip to main content

rusmes_auth/
file.rs

1//! File-based authentication backend (htpasswd-style with bcrypt or argon2id password hashing)
2//!
3//! ## File format (backwards-compatible)
4//!
5//! Each non-empty, non-comment line contains a `username:` prefix followed by a password-hash
6//! field. The hash format is auto-detected by its prefix:
7//!
8//! * `$2a$…`, `$2b$…`, `$2y$…` — bcrypt (any cost)
9//! * `$argon2id$…`, `$argon2i$…`, `$argon2d$…` — argon2 (PHC string format per RFC 9106)
10//!
11//! New password writes use whichever algorithm is configured via
12//! [`FileAuthBackend::with_algorithm`] (default: bcrypt). **Existing hashes verify
13//! regardless of the configured algorithm** — i.e. an argon2-backed deployment
14//! continues to authenticate users whose hashes are still bcrypt, until each user
15//! resets their password.
16//!
17//! If SCRAM-SHA-256 credentials are also stored, they appear as additional
18//! tab-separated columns appended **to the password-hash field** (not as new
19//! colon-separated columns):
20//!
21//! ```text
22//! # Old format — still loads correctly:
23//! alice:$2b$12$...<bcrypt hash>...
24//!
25//! # New format — password hash + tab + four SCRAM columns:
26//! alice:$2b$12$...<bcrypt hash>...\t<salt_base64>\t<iter>\t<stored_key_base64>\t<server_key_base64>
27//!
28//! # Argon2 with SCRAM:
29//! bob:$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>\t<salt_b64>\t<iter>\t<sk_b64>\t<svk_b64>
30//! ```
31//!
32//! The four SCRAM columns are:
33//! * `salt_base64` — Base64-encoded (standard) random salt bytes.
34//! * `iter` — PBKDF2 iteration count (decimal integer).
35//! * `stored_key_base64` — Base64-encoded `SHA-256(ClientKey)` per RFC 5802.
36//! * `server_key_base64` — Base64-encoded `HMAC-SHA-256(SaltedPassword, "Server Key")` per RFC 5802.
37//!
38//! Old lines without the tab extension parse identically to before.
39
40use crate::{AuthBackend, ScramCredentials};
41use anyhow::{anyhow, Context, Result};
42use argon2::{
43    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
44    Argon2,
45};
46use async_trait::async_trait;
47use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
48use rusmes_proto::Username;
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51use std::sync::Arc;
52use tokio::fs;
53use tokio::io::{AsyncReadExt, AsyncWriteExt};
54use tokio::sync::RwLock;
55
56// ============================================================================
57// Hash algorithm selector
58// ============================================================================
59
60/// Password-hashing algorithm used when writing **new** password hashes.
61///
62/// Existing hashes are auto-detected and verified using whichever algorithm
63/// produced them, regardless of the configured value here. The configured
64/// value only governs `create_user` and `change_password`.
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
66pub enum HashAlgorithm {
67    /// bcrypt with the bcrypt crate's `DEFAULT_COST`. Backwards-compatible default.
68    #[default]
69    Bcrypt,
70    /// argon2id with the argon2 crate's defaults (RFC 9106 second-recommended
71    /// parameters: m = 19 MiB, t = 2, p = 1).
72    Argon2,
73}
74
75impl HashAlgorithm {
76    /// Parse a config-string into a [`HashAlgorithm`].
77    ///
78    /// Accepts `"bcrypt"` (case-insensitive) and `"argon2"` /
79    /// `"argon2id"` (case-insensitive). Any other value is an error.
80    pub fn from_config_str(s: &str) -> Result<Self> {
81        match s.to_ascii_lowercase().as_str() {
82            "bcrypt" => Ok(HashAlgorithm::Bcrypt),
83            "argon2" | "argon2id" => Ok(HashAlgorithm::Argon2),
84            other => Err(anyhow!(
85                "unknown hash_algorithm '{}': expected 'bcrypt' or 'argon2'",
86                other
87            )),
88        }
89    }
90}
91
92/// Returns `true` if the supplied hash field appears to be a bcrypt hash.
93fn is_bcrypt_hash(hash: &str) -> bool {
94    hash.starts_with("$2a$") || hash.starts_with("$2b$") || hash.starts_with("$2y$")
95}
96
97/// Returns `true` if the supplied hash field appears to be an argon2 PHC string.
98fn is_argon2_hash(hash: &str) -> bool {
99    hash.starts_with("$argon2id$") || hash.starts_with("$argon2i$") || hash.starts_with("$argon2d$")
100}
101
102// ============================================================================
103// Internal record holding everything we know about a user entry.
104// ============================================================================
105
106/// Internal representation of a single passwd-file user record.
107///
108/// The `hash_field` stores the raw value that appears between the `:` separator
109/// and the newline; for old-format entries that is just the password hash
110/// (bcrypt or argon2 — auto-detected by prefix). For new-format entries it is
111/// `<password_hash>\t<salt_b64>\t<iter>\t<sk_b64>\t<svk_b64>`.
112///
113/// We keep the whole field intact so that round-trips don't destroy data we
114/// didn't understand.
115#[derive(Debug, Clone)]
116struct UserRecord {
117    /// The full hash field (may contain tab-separated SCRAM columns).
118    hash_field: String,
119}
120
121impl UserRecord {
122    /// The password hash alone (everything before the first `\t`).
123    ///
124    /// May be either a bcrypt hash (`$2{a,b,y}$…`) or an argon2 PHC string
125    /// (`$argon2id$…`); use [`is_bcrypt_hash`] / [`is_argon2_hash`] to dispatch.
126    fn password_hash(&self) -> &str {
127        self.hash_field
128            .split('\t')
129            .next()
130            .unwrap_or(&self.hash_field)
131    }
132
133    /// Parse optional SCRAM fields from the tab-separated tail.
134    ///
135    /// Returns `None` if fewer than four SCRAM columns are present (old format).
136    fn scram_credentials(&self) -> Option<ScramCredentials> {
137        let mut parts = self.hash_field.splitn(5, '\t');
138        // Skip the password hash
139        let _ = parts.next()?;
140
141        let salt_b64 = parts.next()?;
142        let iter_str = parts.next()?;
143        let sk_b64 = parts.next()?;
144        let svk_b64 = parts.next()?;
145
146        let salt = BASE64.decode(salt_b64).ok()?;
147        let iteration_count = iter_str.parse::<u32>().ok()?;
148        let stored_key = BASE64.decode(sk_b64).ok()?;
149        let server_key = BASE64.decode(svk_b64).ok()?;
150
151        // Sanity-check: SHA-256 outputs are always 32 bytes
152        if stored_key.len() != 32 || server_key.len() != 32 || salt.is_empty() {
153            return None;
154        }
155
156        Some(ScramCredentials {
157            salt,
158            iteration_count,
159            stored_key,
160            server_key,
161        })
162    }
163
164    /// Rebuild the hash field from a password hash and optional SCRAM credentials.
165    ///
166    /// `password_hash` may be either a bcrypt hash or an argon2 PHC string.
167    fn with_scram(password_hash: &str, creds: &ScramCredentials) -> Self {
168        let salt_b64 = BASE64.encode(&creds.salt);
169        let sk_b64 = BASE64.encode(&creds.stored_key);
170        let svk_b64 = BASE64.encode(&creds.server_key);
171        let hash_field = format!(
172            "{}\t{}\t{}\t{}\t{}",
173            password_hash, salt_b64, creds.iteration_count, sk_b64, svk_b64
174        );
175        Self { hash_field }
176    }
177}
178
179// ============================================================================
180// FileAuthBackend
181// ============================================================================
182
183/// File-based authentication backend supporting bcrypt **and** argon2id password hashing.
184///
185/// New password writes use whichever algorithm was selected via
186/// [`FileAuthBackend::with_algorithm`] (default: bcrypt). Existing hashes are
187/// auto-detected by their PHC prefix and verified with the correct algorithm,
188/// so a deployment can migrate at any time without invalidating existing users.
189///
190/// Also supports storing and fetching RFC 5802 SCRAM-SHA-256 credential bundles
191/// via an extended tab-separated format that is fully backwards-compatible with
192/// the original two-column `username:hash` format.
193pub struct FileAuthBackend {
194    file_path: PathBuf,
195    users: Arc<RwLock<HashMap<String, UserRecord>>>,
196    /// Algorithm used for *new* password writes (`create_user`, `change_password`).
197    /// Existing hashes verify with whichever algorithm produced them, regardless.
198    algorithm: HashAlgorithm,
199}
200
201impl FileAuthBackend {
202    /// Create a new file-based authentication backend using the default algorithm
203    /// ([`HashAlgorithm::Bcrypt`], for backwards compatibility).
204    ///
205    /// If the file does not exist it is created (along with any missing parent
206    /// directories). An existing file is loaded into memory immediately.
207    pub async fn new(file_path: impl AsRef<Path>) -> Result<Self> {
208        Self::with_algorithm(file_path, HashAlgorithm::default()).await
209    }
210
211    /// Create a new file-based authentication backend with an explicit
212    /// password-hashing algorithm for new writes.
213    ///
214    /// Use this constructor when the operator's `[auth.file.hash_algorithm]`
215    /// config selects argon2id. Existing bcrypt hashes in the file remain
216    /// fully functional — they verify using bcrypt regardless of this setting.
217    pub async fn with_algorithm(
218        file_path: impl AsRef<Path>,
219        algorithm: HashAlgorithm,
220    ) -> Result<Self> {
221        let file_path = file_path.as_ref().to_path_buf();
222        let users = Self::load_users(&file_path).await?;
223
224        Ok(Self {
225            file_path,
226            users: Arc::new(RwLock::new(users)),
227            algorithm,
228        })
229    }
230
231    /// Returns the algorithm used for new password writes.
232    pub fn algorithm(&self) -> HashAlgorithm {
233        self.algorithm
234    }
235
236    // -----------------------------------------------------------------------
237    // File I/O helpers
238    // -----------------------------------------------------------------------
239
240    /// Load users from the password file into an in-memory map.
241    async fn load_users(file_path: &Path) -> Result<HashMap<String, UserRecord>> {
242        // Create the file if it doesn't exist
243        if !file_path.exists() {
244            if let Some(parent) = file_path.parent() {
245                fs::create_dir_all(parent)
246                    .await
247                    .context("Failed to create parent directory")?;
248            }
249            fs::File::create(file_path)
250                .await
251                .context("Failed to create password file")?;
252            return Ok(HashMap::new());
253        }
254
255        let mut file = fs::File::open(file_path)
256            .await
257            .context("Failed to open password file")?;
258        let mut contents = String::new();
259        file.read_to_string(&mut contents)
260            .await
261            .context("Failed to read password file")?;
262
263        let mut users = HashMap::new();
264        for (line_num, line) in contents.lines().enumerate() {
265            let line = line.trim();
266            if line.is_empty() || line.starts_with('#') {
267                continue;
268            }
269
270            // Split on the first `:` only; everything after is the hash field
271            // (which may contain additional tab-separated SCRAM columns).
272            let colon_pos = line.find(':').ok_or_else(|| {
273                anyhow!(
274                    "Invalid format on line {}: expected 'username:hash'",
275                    line_num + 1
276                )
277            })?;
278
279            let username = &line[..colon_pos];
280            let hash_field = &line[colon_pos + 1..];
281
282            if username.is_empty() {
283                return Err(anyhow!("Empty username on line {}", line_num + 1));
284            }
285
286            // The password hash is the portion before the first tab (if any
287            // tabs are present for SCRAM columns).
288            let password_hash = hash_field.split('\t').next().unwrap_or(hash_field);
289
290            if !is_bcrypt_hash(password_hash) && !is_argon2_hash(password_hash) {
291                return Err(anyhow!(
292                    "Invalid password hash on line {}: expected bcrypt ($2a$/$2b$/$2y$) or argon2 ($argon2id$/$argon2i$/$argon2d$) prefix",
293                    line_num + 1
294                ));
295            }
296
297            users.insert(
298                username.to_string(),
299                UserRecord {
300                    hash_field: hash_field.to_string(),
301                },
302            );
303        }
304
305        Ok(users)
306    }
307
308    /// Persist the in-memory map to disk atomically.
309    async fn save_users(&self, users: &HashMap<String, UserRecord>) -> Result<()> {
310        let mut contents = String::new();
311        let mut usernames: Vec<&String> = users.keys().collect();
312        usernames.sort();
313
314        for username in usernames {
315            let record = &users[username];
316            contents.push_str(&format!("{}:{}\n", username, record.hash_field));
317        }
318
319        // Write to a temp file then atomically rename.
320        let temp_path = self.file_path.with_extension("tmp");
321        let mut file = fs::File::create(&temp_path)
322            .await
323            .context("Failed to create temporary file")?;
324        file.write_all(contents.as_bytes())
325            .await
326            .context("Failed to write to temporary file")?;
327        file.sync_all()
328            .await
329            .context("Failed to sync temporary file")?;
330        drop(file);
331
332        fs::rename(&temp_path, &self.file_path)
333            .await
334            .context("Failed to rename temporary file")?;
335
336        Ok(())
337    }
338
339    // -----------------------------------------------------------------------
340    // Password helpers — algorithm-aware (bcrypt or argon2id)
341    // -----------------------------------------------------------------------
342
343    /// Hash a password using the configured algorithm.
344    ///
345    /// * Bcrypt: uses the bcrypt crate's `DEFAULT_COST`.
346    /// * Argon2: uses argon2id with the argon2 crate's defaults (RFC 9106).
347    fn hash_password(&self, password: &str) -> Result<String> {
348        match self.algorithm {
349            HashAlgorithm::Bcrypt => bcrypt::hash(password, bcrypt::DEFAULT_COST)
350                .context("Failed to hash password (bcrypt)"),
351            HashAlgorithm::Argon2 => {
352                let salt = SaltString::generate(&mut OsRng);
353                let argon2 = Argon2::default();
354                argon2
355                    .hash_password(password.as_bytes(), &salt)
356                    .map_err(|e| anyhow!("Failed to hash password (argon2id): {}", e))
357                    .map(|h| h.to_string())
358            }
359        }
360    }
361
362    /// Verify a password against a stored hash.
363    ///
364    /// The algorithm is auto-detected from the hash's PHC prefix; the
365    /// configured [`HashAlgorithm`] is **not** consulted on verify, so
366    /// existing bcrypt hashes continue to authenticate after switching the
367    /// configured algorithm to argon2 (and vice versa).
368    fn verify_password(password: &str, hash: &str) -> Result<bool> {
369        if is_bcrypt_hash(hash) {
370            bcrypt::verify(password, hash).context("Failed to verify password (bcrypt)")
371        } else if is_argon2_hash(hash) {
372            let parsed = PasswordHash::new(hash)
373                .map_err(|e| anyhow!("Failed to parse argon2 PHC string: {}", e))?;
374            match Argon2::default().verify_password(password.as_bytes(), &parsed) {
375                Ok(()) => Ok(true),
376                Err(argon2::password_hash::Error::Password) => Ok(false),
377                Err(e) => Err(anyhow!("Failed to verify password (argon2id): {}", e)),
378            }
379        } else {
380            Err(anyhow!(
381                "Unrecognized password hash format (no bcrypt or argon2 prefix)"
382            ))
383        }
384    }
385
386    // -----------------------------------------------------------------------
387    // Public SCRAM credential management (file-backend-specific)
388    // -----------------------------------------------------------------------
389
390    /// Persist RFC 5802 SCRAM-SHA-256 credentials for `user`.
391    ///
392    /// If the user already has a SCRAM credential bundle it is replaced.
393    /// The bcrypt password hash is preserved unchanged.  Returns an error if
394    /// the user does not exist.
395    ///
396    /// This method is intentionally on `FileAuthBackend` directly (not on the
397    /// `AuthBackend` trait) because it is part of the migration/admin tooling
398    /// surface, not the per-request hot path.
399    pub async fn set_scram_credentials(
400        &self,
401        user: &str,
402        credentials: ScramCredentials,
403    ) -> Result<()> {
404        let mut users = self.users.write().await;
405
406        let record = users
407            .get(user)
408            .ok_or_else(|| anyhow!("User '{}' does not exist", user))?;
409
410        let password_hash = record.password_hash().to_string();
411        let new_record = UserRecord::with_scram(&password_hash, &credentials);
412        users.insert(user.to_string(), new_record);
413
414        self.save_users(&users).await
415    }
416}
417
418// ============================================================================
419// AuthBackend implementation
420// ============================================================================
421
422#[async_trait]
423impl AuthBackend for FileAuthBackend {
424    async fn authenticate(&self, username: &Username, password: &str) -> Result<bool> {
425        let users = self.users.read().await;
426
427        if let Some(record) = users.get(username.as_str()) {
428            Self::verify_password(password, record.password_hash())
429        } else {
430            // User not found — still run a hash verification to prevent timing
431            // attacks. We always run bcrypt here regardless of the configured
432            // algorithm: the goal is just to spend some work, and a constant
433            // bcrypt cost is the cheapest way to keep the failure-path latency
434            // similar to a real bcrypt verification.
435            let _ = bcrypt::verify(
436                password,
437                "$2b$12$dummy_hash_to_prevent_timing_attack_00000000000000000000000000000",
438            );
439            Ok(false)
440        }
441    }
442
443    async fn verify_identity(&self, username: &Username) -> Result<bool> {
444        let users = self.users.read().await;
445        Ok(users.contains_key(username.as_str()))
446    }
447
448    async fn list_users(&self) -> Result<Vec<Username>> {
449        let users = self.users.read().await;
450        let mut usernames = Vec::new();
451
452        for username_str in users.keys() {
453            let username = Username::new(username_str.clone()).context(format!(
454                "Invalid username in password file: {}",
455                username_str
456            ))?;
457            usernames.push(username);
458        }
459
460        usernames.sort_by(|a, b| a.as_str().cmp(b.as_str()));
461        Ok(usernames)
462    }
463
464    async fn create_user(&self, username: &Username, password: &str) -> Result<()> {
465        let mut users = self.users.write().await;
466
467        if users.contains_key(username.as_str()) {
468            return Err(anyhow!("User '{}' already exists", username.as_str()));
469        }
470
471        let hash = self.hash_password(password)?;
472        users.insert(
473            username.as_str().to_string(),
474            UserRecord { hash_field: hash },
475        );
476
477        self.save_users(&users).await
478    }
479
480    async fn delete_user(&self, username: &Username) -> Result<()> {
481        let mut users = self.users.write().await;
482
483        if !users.contains_key(username.as_str()) {
484            return Err(anyhow!("User '{}' does not exist", username.as_str()));
485        }
486
487        users.remove(username.as_str());
488        self.save_users(&users).await
489    }
490
491    async fn change_password(&self, username: &Username, new_password: &str) -> Result<()> {
492        let mut users = self.users.write().await;
493
494        let record = users
495            .get(username.as_str())
496            .ok_or_else(|| anyhow!("User '{}' does not exist", username.as_str()))?;
497
498        // Preserve any existing SCRAM columns — only replace the password hash.
499        let existing_scram = record.scram_credentials();
500        let new_hash = self.hash_password(new_password)?;
501
502        let new_record = match existing_scram {
503            Some(creds) => UserRecord::with_scram(&new_hash, &creds),
504            None => UserRecord {
505                hash_field: new_hash,
506            },
507        };
508        users.insert(username.as_str().to_string(), new_record);
509
510        self.save_users(&users).await
511    }
512
513    // -----------------------------------------------------------------------
514    // SCRAM-SHA-256 — full implementation for the file backend
515    // -----------------------------------------------------------------------
516
517    /// Fetch the RFC 5802 SCRAM-SHA-256 credential bundle for `user`.
518    ///
519    /// Returns `Ok(None)` if no SCRAM columns are stored (old-format entry or
520    /// user was never enrolled in SCRAM).
521    async fn fetch_scram_credentials(&self, user: &str) -> Result<Option<ScramCredentials>> {
522        let users = self.users.read().await;
523        let record = match users.get(user) {
524            Some(r) => r,
525            None => return Ok(None),
526        };
527        Ok(record.scram_credentials())
528    }
529
530    // -----------------------------------------------------------------------
531    // Legacy SCRAM methods — forwarded for compatibility with sasl.rs callers
532    // -----------------------------------------------------------------------
533
534    async fn get_scram_params(&self, username: &str) -> Result<(Vec<u8>, u32)> {
535        let creds = self
536            .fetch_scram_credentials(username)
537            .await?
538            .ok_or_else(|| anyhow!("No SCRAM credentials stored for user '{}'", username))?;
539        Ok((creds.salt, creds.iteration_count))
540    }
541
542    async fn get_scram_stored_key(&self, username: &str) -> Result<Vec<u8>> {
543        let creds = self
544            .fetch_scram_credentials(username)
545            .await?
546            .ok_or_else(|| anyhow!("No SCRAM credentials stored for user '{}'", username))?;
547        Ok(creds.stored_key)
548    }
549
550    async fn get_scram_server_key(&self, username: &str) -> Result<Vec<u8>> {
551        let creds = self
552            .fetch_scram_credentials(username)
553            .await?
554            .ok_or_else(|| anyhow!("No SCRAM credentials stored for user '{}'", username))?;
555        Ok(creds.server_key)
556    }
557
558    async fn store_scram_credentials(
559        &self,
560        username: &Username,
561        salt: Vec<u8>,
562        iterations: u32,
563        stored_key: Vec<u8>,
564        server_key: Vec<u8>,
565    ) -> Result<()> {
566        let creds = ScramCredentials {
567            salt,
568            iteration_count: iterations,
569            stored_key,
570            server_key,
571        };
572        self.set_scram_credentials(username.as_str(), creds).await
573    }
574}
575
576// ============================================================================
577// Tests
578// ============================================================================
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::backends::{
584        ldap::LdapBackend, ldap::LdapConfig, oauth2::OAuth2Backend, oauth2::OAuth2Config,
585    };
586    use crate::{AuthBackendKind, FileBackendConfig};
587    use hmac::{Hmac, Mac};
588    use sha2::Sha256;
589    use std::env;
590    use std::fs as std_fs;
591
592    type HmacSha256 = Hmac<Sha256>;
593
594    // Helper: derive SCRAM credentials from a password using the project's
595    // derivation logic (mirrors ScramSha256Mechanism::compute_* in sasl.rs).
596    fn derive_scram_creds(password: &str, salt: &[u8], iterations: u32) -> ScramCredentials {
597        // SaltedPassword = PBKDF2-SHA-256(password, salt, iterations)
598        let mut salted_password = vec![0u8; 32];
599        pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut salted_password);
600
601        // ClientKey = HMAC-SHA-256(SaltedPassword, "Client Key")
602        let mut mac =
603            HmacSha256::new_from_slice(&salted_password).expect("HMAC accepts any key length");
604        mac.update(b"Client Key");
605        let client_key = mac.finalize().into_bytes();
606
607        // StoredKey = SHA-256(ClientKey)
608        use sha2::Digest;
609        let mut hasher = sha2::Sha256::new();
610        hasher.update(client_key);
611        let stored_key = hasher.finalize().to_vec();
612
613        // ServerKey = HMAC-SHA-256(SaltedPassword, "Server Key")
614        let mut mac2 =
615            HmacSha256::new_from_slice(&salted_password).expect("HMAC accepts any key length");
616        mac2.update(b"Server Key");
617        let server_key = mac2.finalize().into_bytes().to_vec();
618
619        ScramCredentials {
620            salt: salt.to_vec(),
621            iteration_count: iterations,
622            stored_key,
623            server_key,
624        }
625    }
626
627    // -----------------------------------------------------------------------
628    // Test 1: AuthBackendKind::build(File) → authenticate round-trip
629    // -----------------------------------------------------------------------
630    #[tokio::test]
631    async fn auth_backend_kind_build_file() {
632        let dir = env::temp_dir().join(format!("rusmes_auth_kind_build_{}", std::process::id()));
633        std_fs::create_dir_all(&dir).expect("create temp dir");
634        let passwd_path = dir.join("passwd");
635
636        // 1. Build via kind factory
637        let kind = AuthBackendKind::File(FileBackendConfig {
638            path: passwd_path.to_string_lossy().to_string(),
639            hash_algorithm: HashAlgorithm::default(),
640        });
641        let backend = kind.build().await.expect("build file backend");
642
643        // 2. Create a user through the trait
644        let username = Username::new("testuser".to_string()).expect("valid username");
645        backend
646            .create_user(&username, "s3cr3t!")
647            .await
648            .expect("create user");
649
650        // 3. Authenticate — must succeed
651        let ok = backend
652            .authenticate(&username, "s3cr3t!")
653            .await
654            .expect("authenticate");
655        assert!(ok, "correct password must authenticate");
656
657        // 4. Wrong password must fail
658        let bad = backend
659            .authenticate(&username, "wrong")
660            .await
661            .expect("authenticate with wrong pw");
662        assert!(!bad, "wrong password must not authenticate");
663
664        // cleanup
665        std_fs::remove_dir_all(&dir).ok();
666    }
667
668    // -----------------------------------------------------------------------
669    // Test 2: SCRAM credential round-trip + backwards-compat with old format
670    // -----------------------------------------------------------------------
671    #[tokio::test]
672    async fn file_backend_scram_credentials_roundtrip() {
673        let dir = env::temp_dir().join(format!(
674            "rusmes_auth_scram_roundtrip_{}",
675            std::process::id()
676        ));
677        std_fs::create_dir_all(&dir).expect("create temp dir");
678        let passwd_path = dir.join("passwd");
679
680        // ---- Part A: backwards-compat — write an old-format line directly ----
681        // Write a valid bcrypt hash for password "hunter2"
682        let hash = bcrypt::hash("hunter2", 4).expect("bcrypt hash");
683        std_fs::write(&passwd_path, format!("olduser:{}\n", hash))
684            .expect("write old-format passwd");
685
686        let backend = FileAuthBackend::new(&passwd_path)
687            .await
688            .expect("load old-format passwd");
689
690        // Old-format user must still authenticate
691        let user = Username::new("olduser".to_string()).expect("username");
692        assert!(
693            backend.authenticate(&user, "hunter2").await.expect("auth"),
694            "old-format user must authenticate"
695        );
696
697        // Old-format user has no SCRAM credentials
698        let none = backend
699            .fetch_scram_credentials("olduser")
700            .await
701            .expect("fetch scram");
702        assert!(none.is_none(), "old-format user has no SCRAM credentials");
703
704        // ---- Part B: set + fetch SCRAM credentials ----
705        let salt = b"naCl_and_pepper!!"; // 17 bytes
706        let creds = derive_scram_creds("hunter2", salt, 4096);
707
708        backend
709            .set_scram_credentials("olduser", creds.clone())
710            .await
711            .expect("set_scram_credentials");
712
713        let fetched = backend
714            .fetch_scram_credentials("olduser")
715            .await
716            .expect("fetch after set")
717            .expect("credentials must be present");
718
719        assert_eq!(fetched.salt, creds.salt, "salt round-trip");
720        assert_eq!(
721            fetched.iteration_count, creds.iteration_count,
722            "iteration_count round-trip"
723        );
724        assert_eq!(
725            fetched.stored_key, creds.stored_key,
726            "stored_key round-trip"
727        );
728        assert_eq!(
729            fetched.server_key, creds.server_key,
730            "server_key round-trip"
731        );
732
733        // ---- Part C: reload from disk — persistence test ----
734        let backend2 = FileAuthBackend::new(&passwd_path)
735            .await
736            .expect("reload backend");
737        let reloaded = backend2
738            .fetch_scram_credentials("olduser")
739            .await
740            .expect("fetch after reload")
741            .expect("credentials survive disk round-trip");
742        assert_eq!(
743            reloaded.stored_key, creds.stored_key,
744            "persisted stored_key"
745        );
746        assert_eq!(
747            reloaded.server_key, creds.server_key,
748            "persisted server_key"
749        );
750
751        // ---- Part D: bcrypt authentication still works after SCRAM write ----
752        assert!(
753            backend2
754                .authenticate(&user, "hunter2")
755                .await
756                .expect("re-auth"),
757            "bcrypt auth must still work after SCRAM credential write"
758        );
759
760        // cleanup
761        std_fs::remove_dir_all(&dir).ok();
762    }
763
764    // -----------------------------------------------------------------------
765    // Test 3: SQL / LDAP / OAuth2 inherit Ok(None) default
766    // -----------------------------------------------------------------------
767    #[tokio::test]
768    async fn default_fetch_scram_credentials_returns_none() {
769        // LDAP backend — sync constructor, always infallible
770        let ldap = LdapBackend::new(LdapConfig::default());
771        let result = ldap
772            .fetch_scram_credentials("anyuser")
773            .await
774            .expect("LDAP default must not error");
775        assert!(
776            result.is_none(),
777            "LdapBackend must return Ok(None) for fetch_scram_credentials"
778        );
779
780        // OAuth2 backend — sync constructor, always infallible
781        let oauth2 = OAuth2Backend::new(OAuth2Config::default());
782        let result2 = oauth2
783            .fetch_scram_credentials("anyuser")
784            .await
785            .expect("OAuth2 default must not error");
786        assert!(
787            result2.is_none(),
788            "OAuth2Backend must return Ok(None) for fetch_scram_credentials"
789        );
790
791        // Note: SqlBackend requires a live database connection so we skip it
792        // here; it inherits the same default impl as LDAP/OAuth2.
793    }
794
795    // -----------------------------------------------------------------------
796    // Test 4: argon2id round-trip + bcrypt-compat
797    //
798    // Verifies the dual-algorithm contract:
799    //  1. A backend configured for argon2 produces argon2 hashes for new users.
800    //  2. Those hashes verify correctly via the same backend.
801    //  3. A pre-existing bcrypt hash on disk continues to authenticate even
802    //     after the algorithm has been switched to argon2 — the verify path
803    //     dispatches on the stored prefix, not on the configured algorithm.
804    // -----------------------------------------------------------------------
805    #[tokio::test]
806    async fn argon2_roundtrip_and_bcrypt_compat() {
807        let dir = env::temp_dir().join(format!(
808            "rusmes_auth_argon2_roundtrip_{}",
809            std::process::id()
810        ));
811        std_fs::create_dir_all(&dir).expect("create temp dir");
812        let passwd_path = dir.join("passwd");
813
814        // -------- Part A: seed a bcrypt hash directly on disk --------
815        // This simulates a deployment that started life on bcrypt and is
816        // about to switch its hash_algorithm config to argon2.
817        let bcrypt_hash = bcrypt::hash("legacy-pass", 4).expect("bcrypt hash");
818        std_fs::write(&passwd_path, format!("legacyuser:{}\n", bcrypt_hash))
819            .expect("seed bcrypt-only file");
820
821        // Boot the backend with argon2 selected for *new* writes.
822        let backend = FileAuthBackend::with_algorithm(&passwd_path, HashAlgorithm::Argon2)
823            .await
824            .expect("load passwd file");
825        assert_eq!(backend.algorithm(), HashAlgorithm::Argon2);
826
827        // The legacy bcrypt user still authenticates — verify dispatches on
828        // the stored hash's prefix, not on the configured algorithm.
829        let legacy = Username::new("legacyuser".to_string()).expect("legacy username");
830        assert!(
831            backend
832                .authenticate(&legacy, "legacy-pass")
833                .await
834                .expect("auth legacy"),
835            "pre-existing bcrypt hash must still verify under argon2 config"
836        );
837        assert!(
838            !backend
839                .authenticate(&legacy, "wrong")
840                .await
841                .expect("auth legacy bad"),
842            "wrong bcrypt password must fail under argon2 config"
843        );
844
845        // -------- Part B: create_user under argon2 produces argon2 hash --------
846        let new_user = Username::new("alice".to_string()).expect("alice username");
847        backend
848            .create_user(&new_user, "Sup3rSecret!")
849            .await
850            .expect("create alice");
851
852        // Inspect the on-disk file: alice's hash field must start with
853        // "$argon2id$" — the argon2 crate's default variant.
854        let on_disk = std_fs::read_to_string(&passwd_path).expect("read passwd file");
855        let alice_line = on_disk
856            .lines()
857            .find(|l| l.starts_with("alice:"))
858            .expect("alice line");
859        let alice_hash_field = &alice_line["alice:".len()..];
860        let alice_hash = alice_hash_field
861            .split('\t')
862            .next()
863            .expect("alice hash field non-empty");
864        assert!(
865            alice_hash.starts_with("$argon2id$"),
866            "new password under argon2 config must produce $argon2id$ hash, got: {}",
867            alice_hash
868        );
869
870        // Verify Alice's password through the trait.
871        assert!(
872            backend
873                .authenticate(&new_user, "Sup3rSecret!")
874                .await
875                .expect("auth alice"),
876            "argon2 hash must verify with correct password"
877        );
878        assert!(
879            !backend
880                .authenticate(&new_user, "wrong-pass")
881                .await
882                .expect("auth alice bad"),
883            "argon2 hash must reject wrong password"
884        );
885
886        // -------- Part C: legacy bcrypt user still works after argon2 user added --------
887        assert!(
888            backend
889                .authenticate(&legacy, "legacy-pass")
890                .await
891                .expect("re-auth legacy"),
892            "bcrypt user still verifies after argon2 user is created"
893        );
894
895        // -------- Part D: change_password preserves SCRAM AND switches algorithm --------
896        // Add SCRAM creds to alice (deterministic), then change password under
897        // argon2 — the SCRAM bundle must survive the rewrite.
898        let scram = derive_scram_creds("Sup3rSecret!", b"some-salt-bytes!", 4096);
899        backend
900            .set_scram_credentials("alice", scram.clone())
901            .await
902            .expect("set scram on alice");
903        backend
904            .change_password(&new_user, "NewArgon2Pass!")
905            .await
906            .expect("change_password to argon2");
907        let after = backend
908            .fetch_scram_credentials("alice")
909            .await
910            .expect("fetch scram after change_password")
911            .expect("scram preserved");
912        assert_eq!(
913            after.salt, scram.salt,
914            "SCRAM salt preserved across argon2 password change"
915        );
916        assert!(
917            backend
918                .authenticate(&new_user, "NewArgon2Pass!")
919                .await
920                .expect("auth new password"),
921            "argon2 hash from change_password must verify"
922        );
923
924        // -------- Part E: reload from disk, all guarantees still hold --------
925        let backend2 = FileAuthBackend::with_algorithm(&passwd_path, HashAlgorithm::Argon2)
926            .await
927            .expect("reload backend");
928        assert!(
929            backend2
930                .authenticate(&legacy, "legacy-pass")
931                .await
932                .expect("reload bcrypt legacy"),
933            "bcrypt legacy verifies after disk reload"
934        );
935        assert!(
936            backend2
937                .authenticate(&new_user, "NewArgon2Pass!")
938                .await
939                .expect("reload argon2 alice"),
940            "argon2 alice verifies after disk reload"
941        );
942
943        // cleanup
944        std_fs::remove_dir_all(&dir).ok();
945    }
946
947    #[test]
948    fn hash_algorithm_from_config_str_accepts_known_values() {
949        assert_eq!(
950            HashAlgorithm::from_config_str("bcrypt").expect("bcrypt"),
951            HashAlgorithm::Bcrypt
952        );
953        assert_eq!(
954            HashAlgorithm::from_config_str("BCRYPT").expect("BCRYPT"),
955            HashAlgorithm::Bcrypt
956        );
957        assert_eq!(
958            HashAlgorithm::from_config_str("argon2").expect("argon2"),
959            HashAlgorithm::Argon2
960        );
961        assert_eq!(
962            HashAlgorithm::from_config_str("argon2id").expect("argon2id"),
963            HashAlgorithm::Argon2
964        );
965        assert_eq!(
966            HashAlgorithm::from_config_str("Argon2ID").expect("Argon2ID"),
967            HashAlgorithm::Argon2
968        );
969        assert!(HashAlgorithm::from_config_str("scrypt").is_err());
970        assert!(HashAlgorithm::from_config_str("").is_err());
971    }
972}