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;