Skip to main content

fraiseql_auth/local_password/
reset.rs

1//! Password-reset flow for local email + password accounts (#367).
2//!
3//! Extends [`LocalPasswordAuthenticator`](super::LocalPasswordAuthenticator) with a
4//! single-use, TTL-bounded, non-enumerable password reset on top of #412's Argon2
5//! credentials and #411's identity store. Reset tokens live in a new
6//! `core.tb_password_reset_token` table mirroring the #411/#412 RLS posture.
7//!
8//! This ships the reset **primitive** as a library service — no HTTP routes and no
9//! concrete SMTP sender — matching #412's service-only precedent. Email delivery is
10//! abstracted behind the [`ResetEmailSender`] trait; the server wires a concrete impl.
11//!
12//! # Token security model
13//!
14//! - **Selector + verifier.** The token is `b64url(16B selector) "." b64url(32B verifier)`. The
15//!   store keeps the `selector` (non-secret, indexed) and `verifier_hash = sha256(verifier)`.
16//!   Redemption fetches the row `WHERE selector = $1` — no secret in the `WHERE`, so the lookup is
17//!   not an existence oracle — then compares the SHA-256 of the presented verifier against the
18//!   stored hash in **constant time** ([`ConstantTimeOps::compare`]). A full database read cannot
19//!   forge a usable token: it would require a SHA-256 preimage of a 256-bit CSPRNG verifier.
20//!   SHA-256 (not Argon2) is sufficient precisely because the verifier is high-entropy — there is
21//!   no brute-force surface that Argon2's cost would defend.
22//! - **Single-use.** A `used_at` column is stamped atomically on redemption (`UPDATE … WHERE
23//!   used_at IS NULL AND expires_at > now()`); a concurrent second redemption sees zero affected
24//!   rows and is rejected. On success the user's *other* outstanding tokens are invalidated too.
25//! - **Short TTL.** Tokens expire one hour after issuance ([`RESET_TOKEN_TTL_SECS`]).
26//! - **Non-enumerable start.**
27//!   [`start_password_reset`](super::LocalPasswordAuthenticator::start_password_reset) always
28//!   returns `Ok(())`. The email → credential lookup runs on every path, and the email is
29//!   dispatched in a spawned task, so a "no such account" path returns indistinguishably from one
30//!   that issued a token. A token is issued only for an email that has a local credential; unknown
31//!   / OAuth-only emails are a silent no-op.
32//! - **Audit asymmetry.** The caller sees one generic [`AuthError::InvalidToken`] for any
33//!   bad/expired/used token; the audit log records the precise reason (`unknown_selector` /
34//!   `bad_verifier` / `expired` / `used` / `race`).
35//!
36//! ## Deferred (named, not unconsidered)
37//!
38//! - **HTTP endpoints** and a concrete [`ResetEmailSender`] (lettre / bridging the #349 observer
39//!   SMTP path) — deferred to the step that wires #412's login/signup routes.
40//! - **Rate limiting** on `start` (token-issuance flooding) — the same follow-up as #412's login
41//!   rate limiting.
42//! - **Residual start timing.** The token `INSERT` runs only on the account-exists path; that
43//!   sub-millisecond delta is dwarfed by the spawned email dispatch (equal on both paths) and is an
44//!   accepted trade-off, consistent with standard reset designs.
45
46use std::sync::Arc;
47
48use async_trait::async_trait;
49use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
50use chrono::{DateTime, Duration, Utc};
51use sha2::{Digest, Sha256};
52use sqlx::Row;
53
54use super::{LOCAL_PROVIDER, LocalPasswordAuthenticator, db_error, validate_password};
55use crate::{
56    account_linking::normalize_email,
57    audit::logger::{AuditEventType, SecretType, get_audit_logger},
58    constant_time::ConstantTimeOps,
59    error::{AuthError, Result},
60    session::SessionStore,
61};
62
63/// Reset-token lifetime in seconds (1 hour, per #367).
64pub const RESET_TOKEN_TTL_SECS: i64 = 3600;
65
66/// Selector length in bytes (the non-secret, indexed lookup key).
67const SELECTOR_LEN: usize = 16;
68/// Verifier length in bytes (the secret; only its SHA-256 is stored).
69const VERIFIER_LEN: usize = 32;
70
71/// Idempotent DDL for the password-reset token store.
72///
73/// Exposed so a migration runner can apply it explicitly;
74/// [`LocalPasswordAuthenticator::init`](super::LocalPasswordAuthenticator::init) runs it
75/// after the #411 identity DDL (the table FK-references `core.tb_user`). Mirrors the
76/// #411/#412 tables: Trinity `pk_`/`id` columns, deny-by-default RLS (`ENABLE`, not
77/// `FORCE`, so the owning store bypasses while any other role reads zero rows without the
78/// `fraiseql.tenant_id` GUC), and `REVOKE ALL … FROM PUBLIC`.
79pub const PASSWORD_RESET_SCHEMA_SQL: &str = r"
80CREATE SCHEMA IF NOT EXISTS core;
81
82CREATE TABLE IF NOT EXISTS core.tb_password_reset_token (
83    pk_password_reset_token BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
84    id            UUID NOT NULL DEFAULT gen_random_uuid(),
85    fk_user       BIGINT NOT NULL REFERENCES core.tb_user (pk_user) ON DELETE CASCADE,
86    user_id       TEXT NOT NULL,
87    selector      TEXT NOT NULL,
88    verifier_hash BYTEA NOT NULL,
89    expires_at    TIMESTAMPTZ NOT NULL,
90    used_at       TIMESTAMPTZ,
91    tenant_id     UUID,
92    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
93    UNIQUE (selector)
94);
95CREATE INDEX IF NOT EXISTS idx_password_reset_token_fk_user
96    ON core.tb_password_reset_token (fk_user);
97
98-- RLS deny-by-default (mirrors core.tb_user / core.tb_auth_identity / tb_password_credential).
99ALTER TABLE core.tb_password_reset_token ENABLE ROW LEVEL SECURITY;
100
101DROP POLICY IF EXISTS p_password_reset_token_tenant_read ON core.tb_password_reset_token;
102CREATE POLICY p_password_reset_token_tenant_read ON core.tb_password_reset_token
103    FOR SELECT USING (tenant_id = NULLIF(current_setting('fraiseql.tenant_id', true), '')::uuid);
104DROP POLICY IF EXISTS p_password_reset_token_insert ON core.tb_password_reset_token;
105CREATE POLICY p_password_reset_token_insert ON core.tb_password_reset_token
106    FOR INSERT WITH CHECK (true);
107
108-- Least-privilege baseline: never world-readable. RLS is defence-in-depth on top.
109REVOKE ALL ON core.tb_password_reset_token FROM PUBLIC;
110";
111
112/// Delivers a password-reset link to a user's email address.
113///
114/// Defined in `fraiseql-auth` so the reset flow stays transport-agnostic and fully
115/// unit-testable; the server provides a concrete implementation (e.g. `lettre`, or
116/// bridging the #349 observer SMTP path). The `token` is the full opaque reset token to
117/// embed in the link — it is never persisted (only its selector and verifier hash are).
118// async_trait: dyn-dispatch required (Arc<dyn ResetEmailSender>); remove when RTN + Send
119// is stable (RFC 3425)
120#[async_trait]
121pub trait ResetEmailSender: Send + Sync {
122    /// Send the reset link carrying `token` to `to`.
123    ///
124    /// # Errors
125    ///
126    /// Returns an [`AuthError`] if delivery fails. The reset flow dispatches this in a
127    /// spawned task and only logs failures, so an error never leaks account existence to
128    /// the requester.
129    async fn send_reset_link(&self, to: &str, token: &str) -> Result<()>;
130}
131
132/// A freshly generated reset token: a non-secret selector plus a secret verifier.
133struct ResetToken {
134    selector: [u8; SELECTOR_LEN],
135    verifier: [u8; VERIFIER_LEN],
136}
137
138/// The redemption-relevant parts of a presented token: the selector (for lookup) and the
139/// SHA-256 of the verifier (for constant-time comparison against the stored hash).
140struct ParsedToken {
141    selector:      String,
142    verifier_hash: Vec<u8>,
143}
144
145impl ResetToken {
146    /// Generate a token from the OS-seeded CSPRNG ([`rand::rng`], as used for refresh
147    /// tokens).
148    fn generate() -> Self {
149        use rand::RngCore as _;
150        let mut selector = [0u8; SELECTOR_LEN];
151        let mut verifier = [0u8; VERIFIER_LEN];
152        rand::rng().fill_bytes(&mut selector);
153        rand::rng().fill_bytes(&mut verifier);
154        Self { selector, verifier }
155    }
156
157    /// The base64url selector, stored as the indexed lookup key.
158    fn selector_b64(&self) -> String {
159        URL_SAFE_NO_PAD.encode(self.selector)
160    }
161
162    /// SHA-256 of the verifier, the only verifier-derived value persisted.
163    fn verifier_hash(&self) -> Vec<u8> {
164        Sha256::digest(self.verifier).to_vec()
165    }
166
167    /// The opaque token string handed to the user: `selector "." verifier`.
168    fn to_token_string(&self) -> String {
169        format!(
170            "{}.{}",
171            URL_SAFE_NO_PAD.encode(self.selector),
172            URL_SAFE_NO_PAD.encode(self.verifier)
173        )
174    }
175
176    /// Parse a presented token into its lookup selector and verifier hash.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`AuthError::InvalidToken`] if the token is not `selector.verifier`, either
181    /// half is not valid base64url, or either decodes to the wrong length.
182    fn parse(token: &str) -> Result<ParsedToken> {
183        let (selector_b64, verifier_b64) =
184            token.split_once('.').ok_or_else(|| AuthError::InvalidToken {
185                reason: "reset token is not in selector.verifier form".to_string(),
186            })?;
187        let selector =
188            URL_SAFE_NO_PAD.decode(selector_b64).map_err(|_| AuthError::InvalidToken {
189                reason: "reset token selector is not valid base64url".to_string(),
190            })?;
191        let verifier =
192            URL_SAFE_NO_PAD.decode(verifier_b64).map_err(|_| AuthError::InvalidToken {
193                reason: "reset token verifier is not valid base64url".to_string(),
194            })?;
195        if selector.len() != SELECTOR_LEN || verifier.len() != VERIFIER_LEN {
196            return Err(AuthError::InvalidToken {
197                reason: "reset token has an unexpected length".to_string(),
198            });
199        }
200        Ok(ParsedToken {
201            selector:      selector_b64.to_string(),
202            verifier_hash: Sha256::digest(&verifier).to_vec(),
203        })
204    }
205}
206
207/// The generic error returned to the caller for any unredeemable token. The precise
208/// reason is recorded in the audit log, never disclosed to the caller.
209fn invalid_reset_token() -> AuthError {
210    AuthError::InvalidToken {
211        reason: "invalid, expired, or already-used password reset token".to_string(),
212    }
213}
214
215impl LocalPasswordAuthenticator {
216    /// Attach the [`ResetEmailSender`] used to deliver reset links.
217    ///
218    /// Without it, [`start_password_reset`](Self::start_password_reset) still issues and
219    /// persists a token but logs a warning instead of delivering it.
220    #[must_use]
221    pub fn with_email_sender(mut self, sender: Arc<dyn ResetEmailSender>) -> Self {
222        self.email_sender = Some(sender);
223        self
224    }
225
226    /// Attach the session store whose sessions are revoked on a successful reset.
227    ///
228    /// Without it, [`confirm_password_reset`](Self::confirm_password_reset) changes the
229    /// password but logs a warning that outstanding sessions were not revoked.
230    #[must_use]
231    pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
232        self.session_store = Some(store);
233        self
234    }
235
236    /// Begin a password reset for `email`. Always returns `Ok(())` (non-enumerable).
237    ///
238    /// Resolves the local credential for `email`; if one exists, issues a single-use,
239    /// one-hour token, persists its selector + verifier hash, and dispatches the reset
240    /// link via the configured [`ResetEmailSender`] in a spawned task. An unknown or
241    /// OAuth-only email is a silent no-op. The return value and timing do not reveal
242    /// whether an account exists.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`AuthError::DatabaseError`] only if the credential lookup or the token
247    /// insert fails — i.e. infrastructure errors, never "account does not exist".
248    pub async fn start_password_reset(&self, email: &str) -> Result<()> {
249        let normalized = normalize_email(email);
250        let logger = get_audit_logger();
251
252        // The lookup runs on every path so a missing account cannot be timed apart.
253        let row = sqlx::query(
254            "SELECT c.fk_user, c.user_id \
255             FROM core.tb_password_credential c \
256             JOIN core.tb_auth_identity i ON i.fk_user = c.fk_user \
257             WHERE i.provider = $1 AND i.provider_id = $2",
258        )
259        .bind(LOCAL_PROVIDER)
260        .bind(&normalized)
261        .fetch_optional(&self.db)
262        .await
263        .map_err(|e| db_error("lookup credential for reset", &e))?;
264
265        let Some(row) = row else {
266            // No local credential — silent no-op. Audited, not surfaced.
267            logger.log_failure(
268                AuditEventType::AuthFailure,
269                SecretType::SessionToken,
270                None,
271                "password_reset_start",
272                "no_local_credential",
273            );
274            return Ok(());
275        };
276
277        let fk_user: i64 = row.get("fk_user");
278        let user_id: String = row.get("user_id");
279
280        let token = ResetToken::generate();
281        let expires_at = Utc::now() + Duration::seconds(RESET_TOKEN_TTL_SECS);
282
283        sqlx::query(
284            "INSERT INTO core.tb_password_reset_token \
285             (fk_user, user_id, selector, verifier_hash, expires_at) \
286             VALUES ($1, $2, $3, $4, $5)",
287        )
288        .bind(fk_user)
289        .bind(&user_id)
290        .bind(token.selector_b64())
291        .bind(token.verifier_hash())
292        .bind(expires_at)
293        .execute(&self.db)
294        .await
295        .map_err(|e| db_error("insert reset token", &e))?;
296
297        // Dispatch in a spawned task: the email I/O latency never leaks existence, and a
298        // delivery failure is logged rather than surfaced to the requester.
299        if let Some(sender) = self.email_sender.clone() {
300            let to = normalized;
301            let token_str = token.to_token_string();
302            tokio::spawn(async move {
303                if let Err(e) = sender.send_reset_link(&to, &token_str).await {
304                    tracing::warn!("password_reset_start: reset email dispatch failed: {e}");
305                }
306            });
307        } else {
308            tracing::warn!(
309                "password_reset_start: token issued but no ResetEmailSender is configured; \
310                 the reset link was not delivered"
311            );
312        }
313
314        logger.log_success(
315            AuditEventType::AuthSuccess,
316            SecretType::SessionToken,
317            Some(user_id),
318            "password_reset_start",
319        );
320        Ok(())
321    }
322
323    /// Redeem a reset `token` and set `new_password`.
324    ///
325    /// Validates the new password's length policy, looks the token up by selector,
326    /// verifies the verifier in constant time, and rejects it if expired or already used.
327    /// On success it sets the new Argon2id hash, marks the token used, invalidates the
328    /// user's other outstanding tokens, and revokes the user's sessions (if a session
329    /// store is wired) — all in one transaction for the credential changes.
330    ///
331    /// # Errors
332    ///
333    /// - [`AuthError::InvalidRegistration`] if `new_password` violates the length policy.
334    /// - [`AuthError::InvalidToken`] for any unredeemable token (unknown / malformed / expired /
335    ///   used / wrong verifier) — one generic error; the audit log records the precise reason.
336    /// - [`AuthError::DatabaseError`] / [`AuthError::Internal`] on a storage failure.
337    pub async fn confirm_password_reset(&self, token: &str, new_password: &str) -> Result<()> {
338        validate_password(new_password)?;
339        let logger = get_audit_logger();
340
341        let Ok(parsed) = ResetToken::parse(token) else {
342            logger.log_failure(
343                AuditEventType::AuthFailure,
344                SecretType::SessionToken,
345                None,
346                "password_reset_confirm",
347                "malformed_token",
348            );
349            return Err(invalid_reset_token());
350        };
351
352        let row = sqlx::query(
353            "SELECT pk_password_reset_token AS pk, fk_user, user_id, verifier_hash, \
354                    expires_at, used_at \
355             FROM core.tb_password_reset_token WHERE selector = $1",
356        )
357        .bind(&parsed.selector)
358        .fetch_optional(&self.db)
359        .await
360        .map_err(|e| db_error("lookup reset token", &e))?;
361
362        let Some(row) = row else {
363            logger.log_failure(
364                AuditEventType::AuthFailure,
365                SecretType::SessionToken,
366                None,
367                "password_reset_confirm",
368                "unknown_selector",
369            );
370            return Err(invalid_reset_token());
371        };
372
373        let pk: i64 = row.get("pk");
374        let fk_user: i64 = row.get("fk_user");
375        let user_id: String = row.get("user_id");
376        let stored_hash: Vec<u8> = row.get("verifier_hash");
377        let expires_at: DateTime<Utc> = row.get("expires_at");
378        let used_at: Option<DateTime<Utc>> = row.get("used_at");
379
380        // Constant-time verifier comparison. The selector is high-entropy and known to the
381        // holder, so an early return on a missing row leaks nothing; the secret check is
382        // the verifier hash, which is always compared in constant time when a row exists.
383        if !ConstantTimeOps::compare(&stored_hash, &parsed.verifier_hash) {
384            logger.log_failure(
385                AuditEventType::AuthFailure,
386                SecretType::SessionToken,
387                Some(user_id),
388                "password_reset_confirm",
389                "bad_verifier",
390            );
391            return Err(invalid_reset_token());
392        }
393
394        if used_at.is_some() {
395            logger.log_failure(
396                AuditEventType::AuthFailure,
397                SecretType::SessionToken,
398                Some(user_id),
399                "password_reset_confirm",
400                "used",
401            );
402            return Err(invalid_reset_token());
403        }
404        if expires_at <= Utc::now() {
405            logger.log_failure(
406                AuditEventType::AuthFailure,
407                SecretType::SessionToken,
408                Some(user_id),
409                "password_reset_confirm",
410                "expired",
411            );
412            return Err(invalid_reset_token());
413        }
414
415        let new_hash = self.hash_password(new_password)?;
416
417        let mut tx = self.db.begin().await.map_err(|e| db_error("begin reset transaction", &e))?;
418
419        // Atomic single-use guard: mark THIS token used only if still unused and unexpired.
420        // A concurrent redemption that already consumed it affects zero rows -> abort.
421        let consumed = sqlx::query(
422            "UPDATE core.tb_password_reset_token SET used_at = now() \
423             WHERE pk_password_reset_token = $1 AND used_at IS NULL AND expires_at > now()",
424        )
425        .bind(pk)
426        .execute(&mut *tx)
427        .await
428        .map_err(|e| db_error("consume reset token", &e))?;
429
430        if consumed.rows_affected() == 0 {
431            tx.rollback().await.map_err(|e| db_error("rollback reset transaction", &e))?;
432            logger.log_failure(
433                AuditEventType::AuthFailure,
434                SecretType::SessionToken,
435                Some(user_id),
436                "password_reset_confirm",
437                "race",
438            );
439            return Err(invalid_reset_token());
440        }
441
442        let updated = sqlx::query(
443            "UPDATE core.tb_password_credential SET password_hash = $1, updated_at = now() \
444             WHERE fk_user = $2",
445        )
446        .bind(&new_hash)
447        .bind(fk_user)
448        .execute(&mut *tx)
449        .await
450        .map_err(|e| db_error("update credential on reset", &e))?;
451
452        if updated.rows_affected() == 0 {
453            tx.rollback().await.map_err(|e| db_error("rollback reset transaction", &e))?;
454            return Err(AuthError::Internal {
455                message: "reset token resolved to a user with no local credential".to_string(),
456            });
457        }
458
459        // Invalidate the user's other outstanding tokens (the consumed one is already
460        // used_at IS NOT NULL, so this excludes it).
461        sqlx::query(
462            "UPDATE core.tb_password_reset_token SET used_at = now() \
463             WHERE fk_user = $1 AND used_at IS NULL",
464        )
465        .bind(fk_user)
466        .execute(&mut *tx)
467        .await
468        .map_err(|e| db_error("invalidate sibling reset tokens", &e))?;
469
470        tx.commit().await.map_err(|e| db_error("commit reset transaction", &e))?;
471
472        // Best-effort session revocation (a different store/schema; outside the tx).
473        if let Some(store) = self.session_store.as_ref() {
474            if let Err(e) = store.revoke_all_sessions(&user_id).await {
475                tracing::warn!(
476                    "password_reset_confirm: session revocation failed for {user_id}: {e}"
477                );
478            }
479        } else {
480            tracing::warn!(
481                "password_reset_confirm: no session store configured; outstanding sessions for \
482                 {user_id} were not revoked"
483            );
484        }
485
486        logger.log_success(
487            AuditEventType::AuthSuccess,
488            SecretType::SessionToken,
489            Some(user_id),
490            "password_reset_confirm",
491        );
492        Ok(())
493    }
494}
495
496#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
497#[cfg(test)]
498mod tests;