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;