Skip to main content

fraiseql_auth/
local_password.rs

1//! Local email + password authentication using Argon2id.
2//!
3//! [`LocalPasswordAuthenticator`] adds an email/password sign-in method on top of the
4//! #411 durable identity store. It is the durable counterpart to the OAuth/OIDC flows:
5//! signup resolves or creates a user through the existing [`AccountStore`]
6//! (provider `"local"`), and the password hash is stored separately in
7//! `core.tb_password_credential` — never adjacent to plaintext, never in the user row.
8//!
9//! # Security design
10//!
11//! - **Argon2id**, memory-hard, with per-credential random salts. Verification is constant-time
12//!   (the `password-hash` crate compares via `subtle`).
13//! - **provider_id is the normalized email.** The local identity keys on `(provider = "local",
14//!   provider_id = normalize_email(email))`, reusing the `UNIQUE (provider, provider_id)` index on
15//!   `core.tb_auth_identity` as the login lookup key — one source of truth, no extra column.
16//! - **Signup is fail-closed.** It links with `email_verified = false`, so a local signup keys its
17//!   own `(local, email)` account and can never auto-merge into an existing verified-email account
18//!   (the H26 protection against takeover via an unverified signup). `core.tb_user.email` therefore
19//!   stays `NULL` until a future verification flow promotes it.
20//! - **Login is non-enumerable.** An unknown user and a wrong password are indistinguishable: both
21//!   return [`AuthError::InvalidCredentials`] with the same body, and both pay the full Argon2 cost
22//!   — an unknown user is verified against a pre-computed dummy hash built from the *same*
23//!   parameters as live credentials, so timing does not leak existence. The email → credential
24//!   lookup runs on both paths, so the database round-trip cannot leak existence either.
25//! - **Disabled is a deliberate, narrow disclosure.** [`AuthError::AccountDisabled`] is returned
26//!   only when the supplied password is *correct*; a wrong password against a disabled account
27//!   still returns [`AuthError::InvalidCredentials`]. Disclosing "this account is disabled" to a
28//!   party already holding valid credentials is an accepted trade-off for this threat model
29//!   ("disabled" = administratively suspended local sign-in). It is never reachable without the
30//!   correct password, so it is not an existence oracle.
31//! - **Audit asymmetry.** The client sees one merged error; the server audit log records the
32//!   precise reason (`unknown_user` / `wrong_password` / `disabled`) under
33//!   [`AuditEventType::AuthFailure`].
34//! - **Rehash on policy change.** A successful login whose stored hash was produced with weaker
35//!   parameters than the current policy is transparently re-hashed and updated.
36//!
37//! ## Deferred (intentionally out of scope for v1)
38//!
39//! - **Rate limiting / lockout** on repeated failures. Argon2's cost throttles online guessing only
40//!   so far; per-account/IP backoff is a follow-up (a lockout is itself a disabled-state with the
41//!   same disclosure trade-off as above).
42//! - **Non-enumerable signup.** [`AuthError::EmailAlreadyRegistered`] is a signup existence oracle;
43//!   the standard "we emailed you" mitigation needs the email-action path (#349), not yet shipped.
44//! - **Password reset / email verification** — #367, reusing the #349 email path.
45
46use std::sync::Arc;
47
48use argon2::{
49    Algorithm, Argon2, Params, Version,
50    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
51};
52use sqlx::{Row, postgres::PgPool};
53
54use crate::{
55    account_linking::{AccountStore, SCHEMA_SQL as IDENTITY_SCHEMA_SQL, normalize_email},
56    audit::logger::{AuditEventType, SecretType, get_audit_logger},
57    error::{AuthError, Result},
58    session::SessionStore,
59};
60
61mod reset;
62
63pub use reset::{PASSWORD_RESET_SCHEMA_SQL, RESET_TOKEN_TTL_SECS, ResetEmailSender};
64
65/// Provider name recorded for local-password identities in `core.tb_auth_identity`.
66const LOCAL_PROVIDER: &str = "local";
67
68/// Minimum password length in bytes. A floor, not a policy engine — see the module docs
69/// for the deferred configurable-policy work.
70const MIN_PASSWORD_LEN: usize = 12;
71
72/// Maximum password length in bytes. Argon2 has no inherent maximum, but hashing an
73/// unbounded input is a denial-of-service vector, so oversize passwords are rejected.
74const MAX_PASSWORD_LEN: usize = 4096;
75
76/// Fixed input hashed once at construction to produce the timing-equalization dummy
77/// hash. Its value is irrelevant — the dummy hash exists only to make an unknown-user
78/// verification pay the same Argon2 cost as a real one.
79const DUMMY_PASSWORD: &[u8] = b"fraiseql-local-password-timing-equalization-dummy";
80
81/// Idempotent DDL for the local-password credential store.
82///
83/// Exposed so a migration runner can apply it explicitly;
84/// [`LocalPasswordAuthenticator::init`] runs it (after ensuring the #411 identity schema,
85/// which it FK-references). Mirrors the #411 identity tables: Trinity `pk_`/`fk_`/`id`
86/// columns, deny-by-default RLS (`ENABLE`, not `FORCE`, so the owning store bypasses while
87/// any other role reads zero rows without the `fraiseql.tenant_id` GUC), and
88/// `REVOKE ALL … FROM PUBLIC`.
89pub const PASSWORD_SCHEMA_SQL: &str = r"
90CREATE SCHEMA IF NOT EXISTS core;
91
92CREATE TABLE IF NOT EXISTS core.tb_password_credential (
93    pk_password_credential BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
94    id            UUID NOT NULL DEFAULT gen_random_uuid(),
95    fk_user       BIGINT NOT NULL REFERENCES core.tb_user (pk_user) ON DELETE CASCADE,
96    user_id       TEXT NOT NULL,
97    password_hash TEXT NOT NULL,
98    disabled_at   TIMESTAMPTZ,
99    tenant_id     UUID,
100    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
101    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
102    UNIQUE (fk_user)
103);
104CREATE INDEX IF NOT EXISTS idx_password_credential_user_id
105    ON core.tb_password_credential (user_id);
106
107-- RLS deny-by-default (mirrors core.tb_user / core.tb_auth_identity from #411).
108ALTER TABLE core.tb_password_credential ENABLE ROW LEVEL SECURITY;
109
110DROP POLICY IF EXISTS p_password_credential_tenant_read ON core.tb_password_credential;
111CREATE POLICY p_password_credential_tenant_read ON core.tb_password_credential
112    FOR SELECT USING (tenant_id = NULLIF(current_setting('fraiseql.tenant_id', true), '')::uuid);
113DROP POLICY IF EXISTS p_password_credential_insert ON core.tb_password_credential;
114CREATE POLICY p_password_credential_insert ON core.tb_password_credential
115    FOR INSERT WITH CHECK (true);
116
117-- Least-privilege baseline: never world-readable. RLS is defence-in-depth on top.
118REVOKE ALL ON core.tb_password_credential FROM PUBLIC;
119";
120
121/// Email + password authenticator backed by Argon2id and the #411 identity store.
122///
123/// Construct with [`new`](Self::new) (OWASP-default parameters) or
124/// [`with_params`](Self::with_params) (to tune cost), call [`init`](Self::init) once on
125/// startup, then [`signup`](Self::signup) / [`login`](Self::login). The connecting
126/// `PgPool` role must own (or `BYPASSRLS`) the `core` tables — calling `init` creates
127/// them, so the connecting role owns them by construction.
128pub struct LocalPasswordAuthenticator {
129    db:            PgPool,
130    /// Resolves/creates users at signup (provider `"local"`). Any [`AccountStore`] that
131    /// persists into `core.tb_auth_identity` works; in practice this is
132    /// [`PostgresAccountStore`](crate::PostgresAccountStore), since login resolves
133    /// email → `user_id` through that table.
134    accounts:      Arc<dyn AccountStore>,
135    argon2:        Argon2<'static>,
136    /// A real Argon2id hash, built from `argon2`'s parameters, used to equalize the
137    /// verification cost of an unknown-user login with a real one.
138    dummy_hash:    String,
139    /// Delivers reset links for [`start_password_reset`](Self::start_password_reset).
140    /// Wired via [`with_email_sender`](Self::with_email_sender); `None` issues tokens
141    /// without delivering them (a warning is logged).
142    email_sender:  Option<Arc<dyn reset::ResetEmailSender>>,
143    /// Revoked on a successful [`confirm_password_reset`](Self::confirm_password_reset).
144    /// Wired via [`with_session_store`](Self::with_session_store); `None` skips revocation
145    /// (a warning is logged).
146    session_store: Option<Arc<dyn SessionStore>>,
147}
148
149impl LocalPasswordAuthenticator {
150    /// Create an authenticator with OWASP-default Argon2id parameters.
151    #[must_use]
152    pub fn new(db: PgPool, accounts: Arc<dyn AccountStore>) -> Self {
153        Self::build(db, accounts, Params::DEFAULT)
154    }
155
156    /// Create an authenticator with explicit Argon2id cost parameters: `m_cost` (memory
157    /// in KiB), `t_cost` (iterations), and `p_cost` (parallelism lanes).
158    ///
159    /// Use this to raise the cost over the default, or (in tests) to lower it. A login
160    /// whose stored hash used different parameters is transparently rehashed to these on
161    /// the next successful sign-in.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`AuthError::ConfigError`] if the parameters are not a valid Argon2
166    /// combination (e.g. `m_cost < 8 * p_cost`).
167    pub fn with_params(
168        db: PgPool,
169        accounts: Arc<dyn AccountStore>,
170        m_cost: u32,
171        t_cost: u32,
172        p_cost: u32,
173    ) -> Result<Self> {
174        let params =
175            Params::new(m_cost, t_cost, p_cost, None).map_err(|e| AuthError::ConfigError {
176                message: format!("invalid Argon2 parameters: {e}"),
177            })?;
178        Ok(Self::build(db, accounts, params))
179    }
180
181    /// Shared constructor: wrap a parameter set into an Argon2id context and pre-compute
182    /// the timing-equalization dummy hash.
183    fn build(db: PgPool, accounts: Arc<dyn AccountStore>, params: Params) -> Self {
184        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
185        let dummy_hash = compute_dummy_hash(&argon2);
186        Self {
187            db,
188            accounts,
189            argon2,
190            dummy_hash,
191            email_sender: None,
192            session_store: None,
193        }
194    }
195
196    /// Ensure the identity + credential schema exists (idempotent).
197    ///
198    /// Runs the #411 identity DDL first (the credential table FK-references
199    /// `core.tb_user`) and then the credential DDL, so it is self-sufficient whether or
200    /// not [`PostgresAccountStore::init`](crate::PostgresAccountStore::init) has already
201    /// run. Call once on startup.
202    ///
203    /// # Errors
204    ///
205    /// Returns [`AuthError::DatabaseError`] if the DDL fails.
206    pub async fn init(&self) -> Result<()> {
207        sqlx::raw_sql(IDENTITY_SCHEMA_SQL)
208            .execute(&self.db)
209            .await
210            .map_err(|e| db_error("initialize identity store (prerequisite)", &e))?;
211        sqlx::raw_sql(PASSWORD_SCHEMA_SQL)
212            .execute(&self.db)
213            .await
214            .map_err(|e| db_error("initialize password credential store", &e))?;
215        sqlx::raw_sql(reset::PASSWORD_RESET_SCHEMA_SQL)
216            .execute(&self.db)
217            .await
218            .map_err(|e| db_error("initialize password reset token store", &e))?;
219        Ok(())
220    }
221
222    /// Register a new local email + password account. Returns the stable `user_id`.
223    ///
224    /// Validates the input, resolves or creates the user through the
225    /// [`AccountStore`] with `email_verified = false` (fail-closed —
226    /// no auto-link into a verified-email account), then stores the Argon2id hash.
227    ///
228    /// # Errors
229    ///
230    /// - [`AuthError::InvalidRegistration`] if the email is empty/malformed or the password
231    ///   violates the length policy.
232    /// - [`AuthError::EmailAlreadyRegistered`] if a local credential already exists for this email.
233    /// - [`AuthError::DatabaseError`] / [`AuthError::Internal`] on a storage failure.
234    pub async fn signup(&self, email: &str, password: &str) -> Result<String> {
235        // Validate before any database work or hashing so bad input fails fast.
236        validate_credentials(email, password)?;
237        let normalized = normalize_email(email);
238
239        // Fail-closed: email_verified = false keys the identity on (local, email) and
240        // never merges into an existing verified-email account.
241        let link = self
242            .accounts
243            .link_or_create_user(Some(&normalized), false, LOCAL_PROVIDER, &normalized)
244            .await?;
245        let user_id = link.user_id;
246
247        let pk_user: i64 = sqlx::query("SELECT pk_user FROM core.tb_user WHERE user_id = $1")
248            .bind(&user_id)
249            .fetch_optional(&self.db)
250            .await
251            .map_err(|e| db_error("resolve user for credential", &e))?
252            .ok_or_else(|| AuthError::Internal {
253                message: "user row missing immediately after link_or_create_user".to_string(),
254            })?
255            .get("pk_user");
256
257        let password_hash = self.hash_password(password)?;
258
259        // UNIQUE (fk_user): a second local signup for the same account inserts no row.
260        let result = sqlx::query(
261            "INSERT INTO core.tb_password_credential (fk_user, user_id, password_hash) \
262             VALUES ($1, $2, $3) ON CONFLICT (fk_user) DO NOTHING",
263        )
264        .bind(pk_user)
265        .bind(&user_id)
266        .bind(&password_hash)
267        .execute(&self.db)
268        .await
269        .map_err(|e| db_error("insert credential", &e))?;
270
271        if result.rows_affected() == 0 {
272            return Err(AuthError::EmailAlreadyRegistered);
273        }
274
275        get_audit_logger().log_success(
276            AuditEventType::AuthSuccess,
277            SecretType::SessionToken,
278            Some(user_id.clone()),
279            "local_signup",
280        );
281        Ok(user_id)
282    }
283
284    /// Verify an email + password and return the stable `user_id` on success.
285    ///
286    /// Non-enumerable: an unknown user and a wrong password return the same
287    /// [`AuthError::InvalidCredentials`] and pay the same Argon2 cost (unknown users are
288    /// verified against a same-parameter dummy hash). A correct password on a disabled
289    /// account returns [`AuthError::AccountDisabled`]; a wrong password on a disabled
290    /// account returns [`AuthError::InvalidCredentials`] (no disabled disclosure). A
291    /// successful login rehashes if the stored parameters are weaker than the current
292    /// policy.
293    ///
294    /// # Errors
295    ///
296    /// - [`AuthError::InvalidCredentials`] for unknown user or wrong password.
297    /// - [`AuthError::AccountDisabled`] for a disabled account with the correct password.
298    /// - [`AuthError::DatabaseError`] / [`AuthError::Internal`] on a storage failure.
299    pub async fn login(&self, email: &str, password: &str) -> Result<String> {
300        let normalized = normalize_email(email);
301
302        // Resolve email → credential FIRST so the database round-trip runs on every path
303        // (a missing-row early return would itself be a timing oracle).
304        let row = sqlx::query(
305            "SELECT c.user_id, c.password_hash, (c.disabled_at IS NOT NULL) AS disabled \
306             FROM core.tb_password_credential c \
307             JOIN core.tb_auth_identity i ON i.user_id = c.user_id \
308             WHERE i.provider = $1 AND i.provider_id = $2",
309        )
310        .bind(LOCAL_PROVIDER)
311        .bind(&normalized)
312        .fetch_optional(&self.db)
313        .await
314        .map_err(|e| db_error("lookup credential", &e))?;
315
316        // Verify against the real hash, or the dummy hash for an unknown user, so the
317        // Argon2 cost (and thus timing) is identical either way.
318        let hash_str: String =
319            row.as_ref().map_or_else(|| self.dummy_hash.clone(), |r| r.get("password_hash"));
320        let parsed = PasswordHash::new(&hash_str).map_err(|e| AuthError::Internal {
321            message: format!("stored password hash is unparseable: {e}"),
322        })?;
323        let verified = self.argon2.verify_password(password.as_bytes(), &parsed).is_ok();
324
325        let logger = get_audit_logger();
326        let Some(row) = row else {
327            // Unknown user — indistinguishable from a wrong password to the client.
328            logger.log_failure(
329                AuditEventType::AuthFailure,
330                SecretType::SessionToken,
331                None,
332                "local_login",
333                "unknown_user",
334            );
335            return Err(AuthError::InvalidCredentials);
336        };
337        let user_id: String = row.get("user_id");
338
339        if !verified {
340            logger.log_failure(
341                AuditEventType::AuthFailure,
342                SecretType::SessionToken,
343                Some(user_id),
344                "local_login",
345                "wrong_password",
346            );
347            return Err(AuthError::InvalidCredentials);
348        }
349
350        // Disabled is disclosed only now — after the password is proven correct.
351        let disabled: bool = row.get("disabled");
352        if disabled {
353            logger.log_failure(
354                AuditEventType::AuthFailure,
355                SecretType::SessionToken,
356                Some(user_id),
357                "local_login",
358                "disabled",
359            );
360            return Err(AuthError::AccountDisabled);
361        }
362
363        // Rehash transparently if the stored parameters are weaker than current policy.
364        // A rehash failure must not fail the login — the password was correct; the next
365        // login retries.
366        if needs_rehash(&parsed, self.argon2.params()) {
367            match self.hash_password(password) {
368                Ok(new_hash) => {
369                    if let Err(e) = self.update_hash(&user_id, &new_hash).await {
370                        tracing::warn!("local_login: rehash update failed for {user_id}: {e}");
371                    }
372                },
373                Err(e) => tracing::warn!("local_login: rehash failed for {user_id}: {e}"),
374            }
375        }
376
377        logger.log_success(
378            AuditEventType::AuthSuccess,
379            SecretType::SessionToken,
380            Some(user_id.clone()),
381            "local_login",
382        );
383        Ok(user_id)
384    }
385
386    /// Enable or disable local-password sign-in for an account.
387    ///
388    /// Disabling stamps `disabled_at`; a subsequent [`login`](Self::login) with the
389    /// correct password returns [`AuthError::AccountDisabled`]. Enabling clears it.
390    ///
391    /// # Errors
392    ///
393    /// - [`AuthError::TokenNotFound`] if the user has no local credential.
394    /// - [`AuthError::DatabaseError`] on a storage failure.
395    pub async fn set_password_disabled(&self, user_id: &str, disabled: bool) -> Result<()> {
396        let result = sqlx::query(
397            "UPDATE core.tb_password_credential \
398             SET disabled_at = CASE WHEN $1 THEN now() ELSE NULL END, updated_at = now() \
399             WHERE user_id = $2",
400        )
401        .bind(disabled)
402        .bind(user_id)
403        .execute(&self.db)
404        .await
405        .map_err(|e| db_error("set credential disabled state", &e))?;
406
407        if result.rows_affected() == 0 {
408            return Err(AuthError::TokenNotFound);
409        }
410        Ok(())
411    }
412
413    /// Hash a password with the configured Argon2id parameters and a fresh random salt.
414    fn hash_password(&self, password: &str) -> Result<String> {
415        let salt = SaltString::generate(&mut OsRng);
416        self.argon2
417            .hash_password(password.as_bytes(), &salt)
418            .map(|h| h.to_string())
419            .map_err(|e| AuthError::Internal {
420                message: format!("password hashing failed: {e}"),
421            })
422    }
423
424    /// Persist a re-hashed credential for an existing user.
425    async fn update_hash(&self, user_id: &str, new_hash: &str) -> Result<()> {
426        sqlx::query(
427            "UPDATE core.tb_password_credential \
428             SET password_hash = $1, updated_at = now() WHERE user_id = $2",
429        )
430        .bind(new_hash)
431        .bind(user_id)
432        .execute(&self.db)
433        .await
434        .map_err(|e| db_error("update credential hash", &e))?;
435        Ok(())
436    }
437}
438
439/// Build the timing-equalization dummy hash from an authenticator's parameters.
440fn compute_dummy_hash(argon2: &Argon2<'_>) -> String {
441    let salt = SaltString::generate(&mut OsRng);
442    argon2
443        .hash_password(DUMMY_PASSWORD, &salt)
444        // Hashing a fixed short input with structurally-valid Argon2 parameters cannot
445        // fail; `Params` is validated at construction, so this is unreachable.
446        .expect("Argon2id hashing of the fixed dummy password with valid parameters is infallible")
447        .to_string()
448}
449
450/// Validate a signup email and password without touching the database.
451fn validate_credentials(email: &str, password: &str) -> Result<()> {
452    let trimmed = email.trim();
453    if trimmed.is_empty() || !trimmed.contains('@') {
454        return Err(AuthError::InvalidRegistration {
455            reason: "email is empty or malformed".to_string(),
456        });
457    }
458    validate_password(password)
459}
460
461/// Enforce the password length policy without touching the database.
462///
463/// Shared by signup ([`validate_credentials`]) and password reset so both apply the same
464/// floor and DoS ceiling.
465fn validate_password(password: &str) -> Result<()> {
466    let len = password.len();
467    if len < MIN_PASSWORD_LEN {
468        return Err(AuthError::InvalidRegistration {
469            reason: format!("password must be at least {MIN_PASSWORD_LEN} characters"),
470        });
471    }
472    if len > MAX_PASSWORD_LEN {
473        return Err(AuthError::InvalidRegistration {
474            reason: format!("password exceeds the {MAX_PASSWORD_LEN}-byte maximum"),
475        });
476    }
477    Ok(())
478}
479
480/// Whether a stored hash should be re-hashed because its parameters are weaker than (or
481/// otherwise differ from) the current policy. An unparseable parameter set is treated as
482/// stale.
483fn needs_rehash(stored: &PasswordHash<'_>, current: &Params) -> bool {
484    match Params::try_from(stored) {
485        Ok(p) => {
486            p.m_cost() != current.m_cost()
487                || p.t_cost() != current.t_cost()
488                || p.p_cost() != current.p_cost()
489        },
490        Err(_) => true,
491    }
492}
493
494fn db_error(context: &str, e: &sqlx::Error) -> AuthError {
495    AuthError::DatabaseError {
496        message: format!("{context}: {e}"),
497    }
498}
499
500#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
501#[cfg(test)]
502mod tests;