Skip to main content

rustango/auth_flows/
mod.rs

1//! Pre-built auth flows — password reset + email verification.
2//!
3//! Lightweight helpers that compose the existing `signed_url` + `email`
4//! layers into the canonical signed-token-via-email pattern. You wire the
5//! routes / templates yourself; these handle the token generation +
6//! verification cycle.
7//!
8//! ## Password reset flow
9//!
10//! 1. User requests a reset → call [`PasswordReset::issue`] with their
11//!    email + a callback URL → emit the email yourself with the returned URL.
12//! 2. User clicks link → axum handler parses the URL → call
13//!    [`PasswordReset::verify`] → if Ok, render the "set new password" form.
14//! 3. User submits form → validate + write new hashed password to DB.
15//!
16//! ```ignore
17//! use rustango::auth_flows::{AuthFlowError, LinkScope, PasswordReset};
18//! use std::time::Duration;
19//!
20//! let secret: &[u8] = b"32-byte-app-secret-...";
21//! // The request's tenant; confirm checks the same scope (#2472).
22//! let scope = LinkScope::from(&tenant);
23//!
24//! // Step 1: issue
25//! let url = PasswordReset::issue(
26//!     &scope,
27//!     "https://app.example.com/auth/reset",
28//!     user_id,
29//!     secret,
30//!     Duration::from_secs(3600),
31//! );
32//! mailer.send(&Email::new()
33//!     .to(&user.email)
34//!     .subject("Reset your password")
35//!     .body(&format!("Click here: {url}"))
36//! ).await?;
37//!
38//! // Step 2: verify (in the callback handler)
39//! match PasswordReset::verify(&scope, &incoming_url, secret) {
40//!     Ok(user_id) => { /* render form to capture new password */ }
41//!     Err(AuthFlowError::Expired) => { /* "link expired, request a new one" */ }
42//!     Err(_) => { /* tampered or malformed */ }
43//! }
44//! ```
45//!
46//! ## Email verification flow
47//!
48//! Same pattern with [`EmailVerification`] — issue the URL after signup,
49//! verify on the callback, mark the user's `email_verified_at` column.
50//!
51//! [`EmailVerification`]: crate::auth_flows::EmailVerification
52//! [`PasswordReset::issue`]: crate::auth_flows::PasswordReset::issue
53//! [`PasswordReset::verify`]: crate::auth_flows::PasswordReset::verify
54
55use std::time::Duration;
56
57use crate::signed_url::{sign, verify, SignedUrlError};
58
59/// Errors from auth-flow helpers.
60#[derive(Debug, thiserror::Error, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum AuthFlowError {
63    #[error("token is missing or malformed")]
64    Malformed,
65    #[error("token signature does not match")]
66    InvalidSignature,
67    #[error("token has expired")]
68    Expired,
69    #[error("token is for the wrong purpose ({0})")]
70    WrongPurpose(String),
71    /// `confirm_password_reset_pool` rejected the new password — too
72    /// short, too common, or otherwise out-of-spec. Message is the
73    /// validator's complaint, suitable for surfacing to the user.
74    /// Issue #391.
75    #[error("password rejected: {0}")]
76    WeakPassword(String),
77    /// Driver / SQL failure during the password UPDATE. The message
78    /// carries the underlying driver error; callers typically log it
79    /// and surface a generic "try again" message to the operator.
80    /// Issue #391.
81    #[error("database error: {0}")]
82    Database(String),
83    /// The token was already redeemed (audit N1). Returned by the
84    /// `verify_single_use` variants when the signature has been seen
85    /// before within its lifetime — defeats replay of a leaked
86    /// magic-link / reset link.
87    #[error("token already used")]
88    AlreadyUsed,
89    /// The link was issued for another tenant or audience (#2472).
90    #[error("token is for another scope")]
91    WrongScope,
92}
93
94/// Who a link is for: one tenant, or an app-chosen audience. Signed into
95/// the link and required again on redeem, so a link from one tenant cannot
96/// act on the same user id in another (#2472).
97///
98/// A multi-tenant app takes it from the request's tenant
99/// (`LinkScope::from(&tenant)`, a `Tenant` or `TenantScope`) and confirms
100/// with [`LinkTarget::from`] the same `&tenant`. [`Self::audience`] is for
101/// single-database apps only.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct LinkScope(String);
104
105impl LinkScope {
106    /// Query param holding the scope.
107    const PARAM: &'static str = "scope";
108
109    /// Scope a link to an opaque audience, e.g. `"app"`, in an app with one
110    /// user database. Never equal to a tenant scope.
111    #[must_use]
112    pub fn audience(audience: &str) -> Self {
113        Self(format!("aud:{audience}"))
114    }
115
116    /// The unsigned link: `base` plus `query` and the scope, before any
117    /// `#fragment` and joined to a query `base` already has.
118    fn link(&self, base: &str, query: &str) -> String {
119        let (path, fragment) = base.find('#').map_or((base, ""), |i| base.split_at(i));
120        let path = path.trim_end_matches(['?', '&']);
121        let joiner = if path.contains('?') { '&' } else { '?' };
122        let scope = url_encode(&self.0);
123        format!("{path}{joiner}{query}&{}={scope}{fragment}", Self::PARAM)
124    }
125
126    /// The link's scope must be this one; a link without one is refused.
127    fn check(&self, url: &str) -> Result<(), AuthFlowError> {
128        match extract_query(url, Self::PARAM) {
129            Some(s) if s == self.0 => Ok(()),
130            _ => Err(AuthFlowError::WrongScope),
131        }
132    }
133}
134
135/// A tenant's scope. The org id is in it, so a slug freed by a purge and
136/// taken again does not accept the old tenant's links.
137#[cfg(feature = "tenancy")]
138impl From<&crate::tenancy::Org> for LinkScope {
139    fn from(org: &crate::tenancy::Org) -> Self {
140        let id = org.id.get().copied().unwrap_or_default();
141        Self(format!("tenant:{}:{id}", org.slug))
142    }
143}
144
145#[cfg(feature = "tenancy")]
146impl<DB: crate::sql::sqlx::Database> From<&crate::extractors::Tenant<DB>> for LinkScope {
147    fn from(tenant: &crate::extractors::Tenant<DB>) -> Self {
148        Self::from(&tenant.org)
149    }
150}
151
152#[cfg(feature = "tenancy")]
153impl From<&crate::extractors::TenantScope> for LinkScope {
154    fn from(tenant: &crate::extractors::TenantScope) -> Self {
155        Self::from(&tenant.org)
156    }
157}
158
159/// The pool a confirm writes to, with the scope its links must carry.
160/// From `&Tenant` both come from one value, so they cannot disagree.
161#[cfg(feature = "passwords")]
162pub struct LinkTarget<'a> {
163    pool: &'a crate::sql::Pool,
164    scope: LinkScope,
165}
166
167#[cfg(feature = "passwords")]
168impl<'a> LinkTarget<'a> {
169    /// A single-database app's user pool and its audience.
170    #[must_use]
171    pub fn audience(pool: &'a crate::sql::Pool, audience: &str) -> Self {
172        Self {
173            pool,
174            scope: LinkScope::audience(audience),
175        }
176    }
177}
178
179#[cfg(all(feature = "passwords", feature = "tenancy", feature = "admin"))]
180impl<'a> LinkTarget<'a> {
181    /// `org`'s user pool and scope, for a caller holding the org itself.
182    pub(crate) fn tenant(pool: &'a crate::sql::Pool, org: &crate::tenancy::Org) -> Self {
183        Self {
184            pool,
185            scope: LinkScope::from(org),
186        }
187    }
188}
189
190#[cfg(all(feature = "passwords", feature = "tenancy"))]
191impl<'a, DB: crate::sql::sqlx::Database> From<&'a crate::extractors::Tenant<DB>>
192    for LinkTarget<'a>
193{
194    fn from(tenant: &'a crate::extractors::Tenant<DB>) -> Self {
195        Self {
196            pool: tenant.pool(),
197            scope: LinkScope::from(tenant),
198        }
199    }
200}
201
202/// A [`crate::extractors::TenantScope`] handler's target: its pool and scope.
203#[cfg(all(feature = "passwords", feature = "tenancy"))]
204impl<'a> From<&'a crate::extractors::TenantScope> for LinkTarget<'a> {
205    fn from(tenant: &'a crate::extractors::TenantScope) -> Self {
206        Self {
207            pool: tenant.pool(),
208            scope: LinkScope::from(tenant),
209        }
210    }
211}
212
213/// Verify the signature, purpose and scope of a link.
214fn check_link(
215    url: &str,
216    purpose: &str,
217    scope: &LinkScope,
218    secret: &[u8],
219) -> Result<(), AuthFlowError> {
220    verify(url, secret)?;
221    let found = extract_query(url, "purpose").ok_or(AuthFlowError::Malformed)?;
222    if found != purpose {
223        return Err(AuthFlowError::WrongPurpose(found));
224    }
225    scope.check(url)
226}
227
228impl From<SignedUrlError> for AuthFlowError {
229    fn from(e: SignedUrlError) -> Self {
230        match e {
231            SignedUrlError::MissingSignature | SignedUrlError::MalformedSignature => {
232                Self::Malformed
233            }
234            SignedUrlError::InvalidSignature => Self::InvalidSignature,
235            SignedUrlError::Expired => Self::Expired,
236        }
237    }
238}
239
240// ------------------------------------------------------------------ Password reset
241
242/// Password reset flow helpers.
243pub struct PasswordReset;
244
245impl PasswordReset {
246    const PURPOSE: &'static str = "pwreset";
247
248    /// Build a signed reset URL valid for `ttl`. `base_url` should be your
249    /// public callback (e.g. `"https://app.example.com/auth/reset"`).
250    /// `user_id` is encoded as a query param so the verifier can identify
251    /// the account. The signed issue time lets a password change since end
252    /// the link (#2248). `scope` names the tenant whose pool will redeem it.
253    #[must_use]
254    pub fn issue(
255        scope: &LinkScope,
256        base_url: &str,
257        user_id: i64,
258        secret: &[u8],
259        ttl: Duration,
260    ) -> String {
261        let query = format!(
262            "user_id={}&purpose={}&{}={}",
263            user_id,
264            Self::PURPOSE,
265            Self::ISSUED_AT,
266            chrono::Utc::now().timestamp_micros(),
267        );
268        sign(&scope.link(base_url, &query), secret, Some(ttl))
269    }
270
271    /// Query param holding the issue time, unix microseconds.
272    const ISSUED_AT: &'static str = "iat";
273
274    /// [`Self::verify`] plus the link's issue time; `Expired` for a link
275    /// minted before it carried one.
276    #[cfg(feature = "passwords")]
277    pub(crate) fn verify_issued(
278        scope: &LinkScope,
279        url: &str,
280        secret: &[u8],
281    ) -> Result<(i64, i64), AuthFlowError> {
282        let user_id = Self::verify(scope, url, secret)?;
283        let iat = extract_query(url, Self::ISSUED_AT)
284            .and_then(|s| s.parse::<i64>().ok())
285            .ok_or(AuthFlowError::Expired)?;
286        Ok((user_id, iat))
287    }
288
289    /// Verify a reset URL issued for `scope`. On success returns the
290    /// `user_id` extracted from the URL — caller writes the new password
291    /// against this id.
292    ///
293    /// # Errors
294    /// [`AuthFlowError`] variants describe the failure mode.
295    pub fn verify(scope: &LinkScope, url: &str, secret: &[u8]) -> Result<i64, AuthFlowError> {
296        check_link(url, Self::PURPOSE, scope, secret)?;
297        let user_id_str = extract_query(url, "user_id").ok_or(AuthFlowError::Malformed)?;
298        user_id_str
299            .parse::<i64>()
300            .map_err(|_| AuthFlowError::Malformed)
301    }
302
303    /// Like [`Self::verify`] but enforces **single use** (audit N1):
304    /// records the token as consumed in `cache` so a replay returns
305    /// [`AuthFlowError::AlreadyUsed`]. Prefer this for reset links, or
306    /// enforce single-use yourself by deleting the reset record after a
307    /// successful password change.
308    ///
309    /// # Errors
310    /// As [`Self::verify`], plus [`AuthFlowError::AlreadyUsed`].
311    #[cfg(feature = "cache")]
312    pub async fn verify_single_use(
313        scope: &LinkScope,
314        url: &str,
315        secret: &[u8],
316        cache: &std::sync::Arc<dyn crate::cache::Cache>,
317    ) -> Result<i64, AuthFlowError> {
318        let user_id = Self::verify(scope, url, secret)?;
319        consume_single_use(url, cache).await?;
320        Ok(user_id)
321    }
322}
323
324/// Finish a password reset: verify the reset URL, validate the new
325/// password, hash it, and update the named user row. Returns the
326/// `user_id` on success.
327///
328/// Sensible defaults:
329/// - `user_table` = `"rustango_users"`
330/// - `pk_column` = `"id"`
331/// - `password_column` = `"password_hash"`
332///
333/// The new password must pass [`crate::passwords::strength_score`] —
334/// the same policy `docs/auth-passwords.md` documents, so a password
335/// refused at registration can no longer be set by resetting (#1399).
336///
337/// The link works only while `password_changed_at` is older than it, so
338/// any password change, this reset included, ends every older link (#2248).
339///
340/// Sessions carry a fingerprint of the password hash (#1338), so the new
341/// hash ends every session issued before the reset, an attacker's
342/// included. It also stamps `password_changed_at` as a record (#1449).
343///
344/// Pairs with [`PasswordReset::issue`] — issue the URL, email it,
345/// and call this helper from your POST `/password-reset/confirm`
346/// endpoint to land the new password. Pass `&tenant` as `target` in a
347/// multi-tenant app; a link issued for another scope is refused (#2472).
348///
349/// # Errors
350/// - [`AuthFlowError`] from `PasswordReset::verify` (Malformed,
351///   InvalidSignature, Expired, WrongPurpose, WrongScope).
352/// - [`AuthFlowError::Expired`] too when the password changed since the link.
353/// - [`AuthFlowError::WeakPassword`] when the password fails the policy.
354/// - [`AuthFlowError::Database`] for SQL / driver failures.
355#[cfg(feature = "passwords")]
356pub async fn confirm_password_reset_pool(
357    target: impl Into<LinkTarget<'_>>,
358    url: &str,
359    new_password: &str,
360    secret: &[u8],
361) -> Result<i64, AuthFlowError> {
362    let LinkTarget { pool, scope } = target.into();
363    let (user_id, iat) = PasswordReset::verify_issued(&scope, url, secret)?;
364    check_password_strength(new_password)?;
365    // Not a delegation to `_into`: this form owns `rustango_users`, so it
366    // also stamps `password_changed_at` and ends existing sessions (#1449).
367    write_password_hash(
368        pool,
369        user_id,
370        new_password,
371        "rustango_users",
372        "id",
373        "password_hash",
374        Some(Rotation::since(iat)),
375    )
376    .await
377}
378
379/// As [`confirm_password_reset_pool`] but writes the new hash into a
380/// caller-named table / columns. Use when the user model lives in a
381/// custom table (e.g. tenant `app_users`) rather than the framework's
382/// default `rustango_users`. Issue #391.
383///
384/// **Writes only the password column.** Framework sessions end on the new
385/// hash; a session check your app writes itself must compare the hash too,
386/// or stamp its own column in the same transaction (#1736).
387///
388/// # Errors
389/// Same shape as [`confirm_password_reset_pool`].
390#[cfg(feature = "passwords")]
391#[allow(clippy::too_many_arguments)]
392pub async fn confirm_password_reset_pool_into(
393    target: impl Into<LinkTarget<'_>>,
394    url: &str,
395    new_password: &str,
396    secret: &[u8],
397    user_table: &str,
398    pk_column: &str,
399    password_column: &str,
400) -> Result<i64, AuthFlowError> {
401    let LinkTarget { pool, scope } = target.into();
402    let user_id = PasswordReset::verify(&scope, url, secret)?;
403    check_password_strength(new_password)?;
404    write_password_hash(
405        pool,
406        user_id,
407        new_password,
408        user_table,
409        pk_column,
410        password_column,
411        None,
412    )
413    .await
414}
415
416/// As [`confirm_password_reset_pool`] but the link is **single-use**: the
417/// token is recorded in `cache` and a replay returns
418/// [`AuthFlowError::AlreadyUsed`] (#1399).
419///
420/// `docs/auth-flows.md` recommends single-use for reset links; this is
421/// the helper that can honour it. Without it a leaked copy of the reset
422/// email is a working account takeover for the rest of the TTL, *after*
423/// the legitimate user has completed their reset.
424///
425/// The password policy is checked before the token is consumed, so a
426/// rejected password does not burn the user's link.
427///
428/// # Errors
429/// As [`confirm_password_reset_pool`], plus [`AuthFlowError::AlreadyUsed`].
430#[cfg(all(feature = "passwords", feature = "cache"))]
431pub async fn confirm_password_reset_single_use(
432    target: impl Into<LinkTarget<'_>>,
433    url: &str,
434    new_password: &str,
435    secret: &[u8],
436    cache: &std::sync::Arc<dyn crate::cache::Cache>,
437) -> Result<i64, AuthFlowError> {
438    let LinkTarget { pool, scope } = target.into();
439    let (user_id, iat) = PasswordReset::verify_issued(&scope, url, secret)?;
440    check_password_strength(new_password)?;
441    consume_single_use(url, cache).await?;
442    // As with the non-single-use form: this one owns `rustango_users`,
443    // so it stamps `password_changed_at` too (#1449).
444    write_password_hash(
445        pool,
446        user_id,
447        new_password,
448        "rustango_users",
449        "id",
450        "password_hash",
451        Some(Rotation::since(iat)),
452    )
453    .await
454}
455
456/// As [`confirm_password_reset_single_use`] but writes into a
457/// caller-named table / columns.
458///
459/// # Errors
460/// Same shape as [`confirm_password_reset_single_use`].
461#[cfg(all(feature = "passwords", feature = "cache"))]
462#[allow(clippy::too_many_arguments)]
463pub async fn confirm_password_reset_single_use_into(
464    target: impl Into<LinkTarget<'_>>,
465    url: &str,
466    new_password: &str,
467    secret: &[u8],
468    cache: &std::sync::Arc<dyn crate::cache::Cache>,
469    user_table: &str,
470    pk_column: &str,
471    password_column: &str,
472) -> Result<i64, AuthFlowError> {
473    let LinkTarget { pool, scope } = target.into();
474    let user_id = PasswordReset::verify(&scope, url, secret)?;
475    // Policy before consumption: a weak password must not cost the user
476    // their link, but a replay must not reach the write.
477    check_password_strength(new_password)?;
478    consume_single_use(url, cache).await?;
479    write_password_hash(
480        pool,
481        user_id,
482        new_password,
483        user_table,
484        pk_column,
485        password_column,
486        None,
487    )
488    .await
489}
490
491/// The framework's one password policy, shared with the CLI and the forms.
492#[cfg(feature = "passwords")]
493fn check_password_strength(new_password: &str) -> Result<(), AuthFlowError> {
494    crate::password_validators::check_builtin_form_password(new_password)
495        .map_err(|e| AuthFlowError::WeakPassword(e.message))
496}
497
498/// `password_changed_at`, stamped by a reset and compared with the link's
499/// issue time. Only `rustango_users` is known to have it.
500#[cfg(feature = "passwords")]
501struct Rotation {
502    /// Unix microseconds, so a link asked for just after a change still works.
503    link_issued_at: i64,
504}
505
506#[cfg(feature = "passwords")]
507impl Rotation {
508    const COLUMN: &'static str = "password_changed_at";
509
510    fn since(link_issued_at: i64) -> Self {
511        Self { link_issued_at }
512    }
513
514    fn issued(&self) -> Result<chrono::DateTime<chrono::Utc>, AuthFlowError> {
515        chrono::DateTime::from_timestamp_micros(self.link_issued_at).ok_or(AuthFlowError::Malformed)
516    }
517}
518
519/// Hash and store the new password. Shared by the replayable and
520/// single-use confirm helpers.
521///
522/// `rotation`, when given, stamps `password_changed_at` in the same UPDATE
523/// so older sessions end (#1449), and the UPDATE lands only if the password
524/// has not changed since the link was issued (#2248). `None` for a
525/// caller-named table, which may have no such column.
526#[cfg(feature = "passwords")]
527async fn write_password_hash(
528    pool: &crate::sql::Pool,
529    user_id: i64,
530    new_password: &str,
531    user_table: &str,
532    pk_column: &str,
533    password_column: &str,
534    rotation: Option<Rotation>,
535) -> Result<i64, AuthFlowError> {
536    let hash = crate::passwords::hash_async(new_password)
537        .await
538        .map_err(|e| AuthFlowError::Database(e.to_string()))?;
539    if let Some(rotation) = rotation {
540        let written = rotate_user_password(pool, user_id, hash, &rotation).await?;
541        // A changed password (or a missing row) ends the link (#2248).
542        return if written == 0 {
543            Err(AuthFlowError::Expired)
544        } else {
545            Ok(user_id)
546        };
547    }
548    // Raw: the table and columns are caller-named, so there is no model.
549    let dialect = pool.dialect();
550    let sql = format!(
551        "UPDATE {} SET {} = {} WHERE {} = {}",
552        dialect.quote_ident(user_table),
553        dialect.quote_ident(password_column),
554        dialect.placeholder(1),
555        dialect.quote_ident(pk_column),
556        dialect.placeholder(2),
557    );
558    let args = vec![
559        crate::core::SqlValue::String(hash),
560        crate::core::SqlValue::I64(user_id),
561    ];
562    crate::sql::raw_execute_pool(pool, &sql, args)
563        .await
564        .map_err(|e| AuthFlowError::Database(e.to_string()))?;
565    Ok(user_id)
566}
567
568/// The `rustango_users` columns a reset writes. Built by hand so the
569/// UPDATE goes through the ORM in builds without `tenancy`'s `User` (#2273).
570#[cfg(feature = "passwords")]
571static RESET_USERS: crate::core::ModelSchema = {
572    use crate::core::{FieldSchema, FieldType};
573    const FIELDS: &[FieldSchema] = &[
574        {
575            let mut f = FieldSchema::new("id", "id", FieldType::I64);
576            f.primary_key = true;
577            f
578        },
579        FieldSchema::new("password_hash", "password_hash", FieldType::String),
580        {
581            let mut f = FieldSchema::new(Rotation::COLUMN, Rotation::COLUMN, FieldType::DateTime);
582            f.nullable = true;
583            f
584        },
585    ];
586    let mut s = crate::core::ModelSchema::new("User", "rustango_users");
587    s.fields = FIELDS;
588    s
589};
590
591/// Store `hash` and stamp the rotation, only while the password is
592/// unchanged since the link. Rows written.
593#[cfg(feature = "passwords")]
594async fn rotate_user_password(
595    pool: &crate::sql::Pool,
596    user_id: i64,
597    hash: String,
598    rotation: &Rotation,
599) -> Result<u64, AuthFlowError> {
600    use crate::core::{Assignment, SqlValue, UpdateQuery};
601    use crate::query::Q;
602    let since = rotation.issued()?;
603    let query = UpdateQuery {
604        model: &RESET_USERS,
605        set: vec![
606            Assignment::new("password_hash", SqlValue::String(hash)),
607            Assignment::new(Rotation::COLUMN, SqlValue::DateTime(chrono::Utc::now())),
608        ],
609        where_clause: (Q::eq("id", user_id)
610            & (Q::is_null(Rotation::COLUMN) | Q::lt(Rotation::COLUMN, since)))
611        .into(),
612    };
613    crate::sql::update_pool(pool, &query)
614        .await
615        .map_err(|e| AuthFlowError::Database(e.to_string()))
616}
617
618// ------------------------------------------------------------------ Email verification
619
620/// Email verification flow helpers.
621pub struct EmailVerification;
622
623impl EmailVerification {
624    const PURPOSE: &'static str = "verify_email";
625
626    /// Build a signed verification URL valid for `ttl` (typically 24h+).
627    /// Encodes both `user_id` and `email` so the verifier can confirm the
628    /// user hasn't changed their email between issuance and click.
629    #[must_use]
630    pub fn issue(
631        scope: &LinkScope,
632        base_url: &str,
633        user_id: i64,
634        email: &str,
635        secret: &[u8],
636        ttl: Duration,
637    ) -> String {
638        let query = format!(
639            "user_id={}&email={}&purpose={}",
640            user_id,
641            url_encode(email),
642            Self::PURPOSE,
643        );
644        sign(&scope.link(base_url, &query), secret, Some(ttl))
645    }
646
647    /// Verify the URL and return the `(user_id, email)` it was issued for.
648    /// Caller compares email against the user's current email to detect
649    /// stale verification links.
650    ///
651    /// # Errors
652    /// As [`PasswordReset::verify`].
653    pub fn verify(
654        scope: &LinkScope,
655        url: &str,
656        secret: &[u8],
657    ) -> Result<(i64, String), AuthFlowError> {
658        check_link(url, Self::PURPOSE, scope, secret)?;
659        let user_id_str = extract_query(url, "user_id").ok_or(AuthFlowError::Malformed)?;
660        let user_id = user_id_str
661            .parse::<i64>()
662            .map_err(|_| AuthFlowError::Malformed)?;
663        let email = extract_query(url, "email").ok_or(AuthFlowError::Malformed)?;
664        Ok((user_id, email))
665    }
666
667    /// Like [`Self::verify`] but enforces **single use** (audit N1) via
668    /// `cache` — a replay returns [`AuthFlowError::AlreadyUsed`].
669    ///
670    /// # Errors
671    /// As [`Self::verify`], plus [`AuthFlowError::AlreadyUsed`].
672    #[cfg(feature = "cache")]
673    pub async fn verify_single_use(
674        scope: &LinkScope,
675        url: &str,
676        secret: &[u8],
677        cache: &std::sync::Arc<dyn crate::cache::Cache>,
678    ) -> Result<(i64, String), AuthFlowError> {
679        let out = Self::verify(scope, url, secret)?;
680        consume_single_use(url, cache).await?;
681        Ok(out)
682    }
683}
684
685// ------------------------------------------------------------------ Magic-link login
686
687/// Magic-link login flow — passwordless authentication via emailed URL.
688pub struct MagicLink;
689
690impl MagicLink {
691    const PURPOSE: &'static str = "magic_link";
692
693    /// Build a signed login URL valid for `ttl` (typically 10–30 minutes).
694    /// `email` identifies the user — the verifier uses it to look up the
695    /// account and issue a session.
696    #[must_use]
697    pub fn issue(
698        scope: &LinkScope,
699        base_url: &str,
700        email: &str,
701        secret: &[u8],
702        ttl: Duration,
703    ) -> String {
704        let query = format!("email={}&purpose={}", url_encode(email), Self::PURPOSE);
705        sign(&scope.link(base_url, &query), secret, Some(ttl))
706    }
707
708    /// Verify the URL and return the email it was issued for.
709    ///
710    /// **This is NOT single-use** — the signature is valid until its TTL
711    /// expires, so a leaked link (Referer header, browser history, proxy
712    /// logs) can be replayed to log in repeatedly. For passwordless login
713    /// use [`Self::verify_single_use`] (or otherwise burn the token after
714    /// first use). Audit N1.
715    ///
716    /// # Errors
717    /// As [`PasswordReset::verify`].
718    pub fn verify(scope: &LinkScope, url: &str, secret: &[u8]) -> Result<String, AuthFlowError> {
719        check_link(url, Self::PURPOSE, scope, secret)?;
720        extract_query(url, "email").ok_or(AuthFlowError::Malformed)
721    }
722
723    /// Like [`Self::verify`] but enforces **single use** (audit N1):
724    /// the magic link can be redeemed only once — a replay returns
725    /// [`AuthFlowError::AlreadyUsed`]. Strongly recommended for
726    /// passwordless login, where `verify` alone leaves a replay window.
727    ///
728    /// # Errors
729    /// As [`Self::verify`], plus [`AuthFlowError::AlreadyUsed`].
730    #[cfg(feature = "cache")]
731    pub async fn verify_single_use(
732        scope: &LinkScope,
733        url: &str,
734        secret: &[u8],
735        cache: &std::sync::Arc<dyn crate::cache::Cache>,
736    ) -> Result<String, AuthFlowError> {
737        let email = Self::verify(scope, url, secret)?;
738        consume_single_use(url, cache).await?;
739        Ok(email)
740    }
741}
742
743// ------------------------------------------------------------------ Helpers
744
745fn extract_query(url: &str, key: &str) -> Option<String> {
746    let url = url.split_once('#').map_or(url, |(u, _)| u);
747    let query = url.split_once('?')?.1;
748    for pair in query.split('&') {
749        let Some((k, v)) = pair.split_once('=') else {
750            continue;
751        };
752        let k = url_decode(k);
753        if k == key {
754            return Some(url_decode(v));
755        }
756    }
757    None
758}
759
760/// Mark a (already-verified) signed-URL token as consumed so it can't be
761/// replayed (audit N1). Keys the token's `signature` in the cache for its
762/// remaining lifetime; a repeat within that window returns
763/// [`AuthFlowError::AlreadyUsed`].
764///
765/// One atomic `add`, so of two concurrent redemptions one wins (#1853).
766/// Fails closed: a cache error, or a cache that stores nothing, refuses.
767#[cfg(feature = "cache")]
768async fn consume_single_use(
769    url: &str,
770    cache: &std::sync::Arc<dyn crate::cache::Cache>,
771) -> Result<(), AuthFlowError> {
772    let sig = extract_query(url, "signature").ok_or(AuthFlowError::Malformed)?;
773    if cache.stores_nothing() {
774        tracing::error!(
775            target: "rustango::auth_flows",
776            "single-use token refused: the cache keeps nothing (`NullCache`); use a shared cache"
777        );
778        return Err(AuthFlowError::AlreadyUsed);
779    }
780    let key = format!("authflow_used:{sig}");
781    match cache.add(&key, "1", Some(single_use_ttl(url))).await {
782        Ok(true) => Ok(()),
783        Ok(false) => Err(AuthFlowError::AlreadyUsed),
784        Err(e) => {
785            tracing::error!(target: "rustango::auth_flows", error = %e, "single-use token refused: cache failed");
786            Err(AuthFlowError::AlreadyUsed)
787        }
788    }
789}
790
791/// Remaining lifetime of the token from its `expires` param (unix secs),
792/// so the used-marker lives exactly as long as the token could be valid.
793/// Falls back to 1h when `expires` is absent/unparseable/in the past.
794#[cfg(feature = "cache")]
795fn single_use_ttl(url: &str) -> Duration {
796    let now = std::time::SystemTime::now()
797        .duration_since(std::time::UNIX_EPOCH)
798        .map_or(0, |d| d.as_secs());
799    extract_query(url, "expires")
800        .and_then(|s| s.parse::<u64>().ok())
801        .map(|exp| Duration::from_secs(exp.saturating_sub(now)))
802        .filter(|d| !d.is_zero())
803        .unwrap_or(Duration::from_secs(3600))
804}
805
806// #806 — was byte-identical to `crate::url_codec::url_encode`
807// (RFC 3986 unreserved set — the right alphabet for OAuth-style
808// `?next=...` redirect targets). Route through the canonical codec.
809// Percent-decoder consolidated into [`crate::url_codec`] — see the
810// note in [`crate::signed_url`]. Re-imported under the `url_decode`
811// name to keep the local call sites unchanged.
812use crate::url_codec::{url_decode, url_encode};
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    const SECRET: &[u8] = b"my-test-secret";
819
820    fn scope() -> LinkScope {
821        LinkScope::audience("acme")
822    }
823
824    /// The hand-built schema must match the `User` model it stands in for.
825    #[cfg(all(feature = "passwords", feature = "tenancy"))]
826    #[test]
827    fn reset_users_schema_matches_the_user_model() {
828        use crate::core::Model as _;
829        let user = crate::tenancy::User::SCHEMA;
830        assert_eq!(RESET_USERS.table, user.table);
831        for f in RESET_USERS.fields {
832            let u = user.field_by_column(f.column).expect(f.column);
833            assert_eq!((f.ty, f.nullable), (u.ty, u.nullable), "{}", f.column);
834        }
835    }
836
837    // -------------------------------- Password reset
838
839    #[test]
840    fn password_reset_issue_and_verify_roundtrip() {
841        let url = PasswordReset::issue(
842            &scope(),
843            "https://app.example.com/reset",
844            42,
845            SECRET,
846            Duration::from_secs(3600),
847        );
848        let user_id = PasswordReset::verify(&scope(), &url, SECRET).unwrap();
849        assert_eq!(user_id, 42);
850    }
851
852    /// A base URL with its own query or a `#fragment` still gives a live link:
853    /// the query joins with `&`, and the server, which never sees the
854    /// fragment, verifies what the browser sends.
855    #[test]
856    fn links_keep_the_base_query_and_fragment() {
857        let ttl = Duration::from_secs(600);
858        for base in [
859            "https://app.example.com/reset?lang=en",
860            "https://app.example.com/reset?next=/x&",
861            "https://app.example.com/reset#/spa",
862            "https://app.example.com/reset?lang=en#/spa?x=1",
863        ] {
864            let reset = PasswordReset::issue(&scope(), base, 42, SECRET, ttl);
865            let verify = EmailVerification::issue(&scope(), base, 42, "a@b.c", SECRET, ttl);
866            let magic = MagicLink::issue(&scope(), base, "a@b.c", SECRET, ttl);
867            for url in [&reset, &verify, &magic] {
868                let sent = url.split_once('#').map_or(url.as_str(), |(u, _)| u);
869                if let Some((_, frag)) = base.split_once('#') {
870                    assert!(url.ends_with(&format!("#{frag}")), "{url}");
871                }
872                if base.contains("lang=en") {
873                    assert_eq!(extract_query(sent, "lang").as_deref(), Some("en"), "{url}");
874                }
875                assert_eq!(
876                    PasswordReset::verify(&scope(), sent, SECRET).is_ok(),
877                    url == &reset
878                );
879            }
880            assert_eq!(
881                PasswordReset::verify(&scope(), &reset, SECRET),
882                Ok(42),
883                "{reset}"
884            );
885            assert_eq!(
886                EmailVerification::verify(&scope(), &verify, SECRET),
887                Ok((42, "a@b.c".to_owned())),
888                "{verify}"
889            );
890            let sent = magic.split_once('#').map_or(magic.as_str(), |(u, _)| u);
891            assert_eq!(
892                MagicLink::verify(&scope(), sent, SECRET).as_deref(),
893                Ok("a@b.c")
894            );
895        }
896    }
897
898    /// #2472 — every link kind is refused outside the scope it was issued for.
899    #[test]
900    fn links_are_refused_in_another_scope() {
901        let other = LinkScope::audience("globex");
902        let ttl = Duration::from_secs(600);
903        let reset = PasswordReset::issue(&scope(), "https://x/r", 1, SECRET, ttl);
904        let verify = EmailVerification::issue(&scope(), "https://x/v", 1, "a@x.com", SECRET, ttl);
905        let magic = MagicLink::issue(&scope(), "https://x/l", "a@x.com", SECRET, ttl);
906        assert_eq!(
907            PasswordReset::verify(&other, &reset, SECRET),
908            Err(AuthFlowError::WrongScope)
909        );
910        assert_eq!(
911            EmailVerification::verify(&other, &verify, SECRET),
912            Err(AuthFlowError::WrongScope)
913        );
914        assert_eq!(
915            MagicLink::verify(&other, &magic, SECRET),
916            Err(AuthFlowError::WrongScope)
917        );
918    }
919
920    /// #2472 — the scope is signed: rewriting it breaks the signature.
921    #[test]
922    fn a_rewritten_scope_fails_the_signature() {
923        let url =
924            PasswordReset::issue(&scope(), "https://x/r", 1, SECRET, Duration::from_secs(600));
925        let forged = url.replace("aud%3Aacme", "aud%3Aglobex");
926        assert_ne!(forged, url);
927        let r = PasswordReset::verify(&LinkScope::audience("globex"), &forged, SECRET);
928        assert_eq!(r, Err(AuthFlowError::InvalidSignature));
929    }
930
931    #[cfg(feature = "tenancy")]
932    fn org(slug: &str, id: i64) -> crate::tenancy::Org {
933        crate::tenancy::Org {
934            id: crate::sql::Auto::Set(id),
935            slug: slug.into(),
936            ..crate::testkit::org()
937        }
938    }
939
940    /// #2472 — a tenant scope names the org id, so a slug reused after a
941    /// purge, or an audience of the same name, does not match.
942    #[cfg(all(feature = "tenancy", feature = "testkit"))]
943    #[test]
944    fn a_tenant_scope_is_its_org_not_its_slug() {
945        let old = LinkScope::from(&org("acme", 1));
946        let url = PasswordReset::issue(&old, "https://x/r", 1, SECRET, Duration::from_secs(600));
947        for other in [
948            LinkScope::from(&org("acme", 2)),
949            LinkScope::audience("acme"),
950        ] {
951            assert_eq!(
952                PasswordReset::verify(&other, &url, SECRET),
953                Err(AuthFlowError::WrongScope)
954            );
955        }
956        assert_eq!(PasswordReset::verify(&old, &url, SECRET), Ok(1));
957    }
958
959    /// #2472 — confirm through `&Tenant` takes pool and scope from one
960    /// value: its own links reset, another tenant's are refused.
961    #[cfg(all(feature = "tenancy", feature = "sqlite", feature = "testkit"))]
962    #[tokio::test]
963    async fn confirm_through_a_tenant_checks_its_scope() {
964        use crate::sql::sqlx;
965        let sq = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
966        let pool = crate::sql::Pool::from(sq.clone());
967        crate::testkit::create_tables_for::<crate::tenancy::User>(&pool)
968            .await
969            .unwrap();
970        let mut u = crate::tenancy::User {
971            username: "ada".into(),
972            password_hash: "OLD".into(),
973            ..crate::testkit::user()
974        };
975        u.insert_pool(&pool).await.unwrap();
976        let id = *u.id.get().unwrap();
977        let tenant = crate::extractors::Tenant::for_test_sqlite(org("b", 2), sq);
978        let ttl = Duration::from_secs(600);
979        let strong = "brand-new-strong-password-7!";
980
981        let foreign = PasswordReset::issue(
982            &LinkScope::from(&org("x", 1)),
983            "https://x/r",
984            id,
985            SECRET,
986            ttl,
987        );
988        let r = confirm_password_reset_pool(&tenant, &foreign, strong, SECRET).await;
989        assert_eq!(r, Err(AuthFlowError::WrongScope));
990
991        let own = PasswordReset::issue(&LinkScope::from(&tenant), "https://b/r", id, SECRET, ttl);
992        assert_eq!(
993            confirm_password_reset_pool(&tenant, &own, strong, SECRET).await,
994            Ok(id)
995        );
996    }
997
998    /// A `TenantScope` handler confirms with its own pool and scope, so it
999    /// never needs `LinkTarget::audience` on a tenant pool.
1000    #[cfg(all(feature = "tenancy", feature = "sqlite", feature = "testkit"))]
1001    #[tokio::test]
1002    async fn confirm_through_a_tenant_scope_checks_its_scope() {
1003        use crate::sql::sqlx;
1004        let sq = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
1005        let pool = crate::sql::Pool::from(sq.clone());
1006        crate::testkit::create_tables_for::<crate::tenancy::User>(&pool)
1007            .await
1008            .unwrap();
1009        let mut u = crate::tenancy::User {
1010            username: "ada".into(),
1011            password_hash: "OLD".into(),
1012            ..crate::testkit::user()
1013        };
1014        u.insert_pool(&pool).await.unwrap();
1015        let id = *u.id.get().unwrap();
1016        let scope = crate::extractors::TenantScope::for_test(org("b", 2), pool);
1017        let ttl = Duration::from_secs(600);
1018        let strong = "brand-new-strong-password-7!";
1019
1020        let audience =
1021            PasswordReset::issue(&LinkScope::audience("app"), "https://b/r", id, SECRET, ttl);
1022        let r = confirm_password_reset_pool(&scope, &audience, strong, SECRET).await;
1023        assert_eq!(r, Err(AuthFlowError::WrongScope));
1024
1025        let own = PasswordReset::issue(&LinkScope::from(&scope), "https://b/r", id, SECRET, ttl);
1026        assert_eq!(
1027            confirm_password_reset_pool(&scope, &own, strong, SECRET).await,
1028            Ok(id)
1029        );
1030    }
1031
1032    /// A signed link minted before scopes existed is refused.
1033    #[test]
1034    fn an_unscoped_link_is_refused() {
1035        let url = sign(
1036            "https://x/r?user_id=1&purpose=pwreset&iat=1",
1037            SECRET,
1038            Some(Duration::from_secs(60)),
1039        );
1040        let r = PasswordReset::verify(&scope(), &url, SECRET);
1041        assert_eq!(r, Err(AuthFlowError::WrongScope));
1042    }
1043
1044    #[test]
1045    fn password_reset_wrong_secret_fails() {
1046        let url = PasswordReset::issue(
1047            &scope(),
1048            "https://x/r",
1049            42,
1050            SECRET,
1051            Duration::from_secs(3600),
1052        );
1053        let r = PasswordReset::verify(&scope(), &url, b"different");
1054        assert_eq!(r.unwrap_err(), AuthFlowError::InvalidSignature);
1055    }
1056
1057    #[test]
1058    fn password_reset_tampered_user_id_fails() {
1059        let url = PasswordReset::issue(
1060            &scope(),
1061            "https://x/r",
1062            42,
1063            SECRET,
1064            Duration::from_secs(3600),
1065        );
1066        let tampered = url.replace("user_id=42", "user_id=99");
1067        let r = PasswordReset::verify(&scope(), &tampered, SECRET);
1068        assert_eq!(r.unwrap_err(), AuthFlowError::InvalidSignature);
1069    }
1070
1071    #[test]
1072    fn password_reset_rejects_email_verification_token() {
1073        // Same URL shape but issued for a different purpose
1074        let url = EmailVerification::issue(
1075            &scope(),
1076            "https://x/r",
1077            42,
1078            "alice@x.com",
1079            SECRET,
1080            Duration::from_secs(3600),
1081        );
1082        let r = PasswordReset::verify(&scope(), &url, SECRET);
1083        assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
1084    }
1085
1086    // -------------------------------- Email verification
1087
1088    #[test]
1089    fn email_verification_roundtrip() {
1090        let url = EmailVerification::issue(
1091            &scope(),
1092            "https://x/v",
1093            42,
1094            "alice@example.com",
1095            SECRET,
1096            Duration::from_secs(86_400),
1097        );
1098        let (uid, email) = EmailVerification::verify(&scope(), &url, SECRET).unwrap();
1099        assert_eq!(uid, 42);
1100        assert_eq!(email, "alice@example.com");
1101    }
1102
1103    #[test]
1104    fn email_verification_handles_special_chars() {
1105        let url = EmailVerification::issue(
1106            &scope(),
1107            "https://x/v",
1108            42,
1109            "a+b@example.com",
1110            SECRET,
1111            Duration::from_secs(86_400),
1112        );
1113        let (_, email) = EmailVerification::verify(&scope(), &url, SECRET).unwrap();
1114        assert_eq!(email, "a+b@example.com");
1115    }
1116
1117    #[test]
1118    fn email_verification_rejects_password_reset_token() {
1119        let url = PasswordReset::issue(
1120            &scope(),
1121            "https://x/v",
1122            42,
1123            SECRET,
1124            Duration::from_secs(3600),
1125        );
1126        let r = EmailVerification::verify(&scope(), &url, SECRET);
1127        assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
1128    }
1129
1130    // -------------------------------- Magic link
1131
1132    #[test]
1133    fn magic_link_roundtrip() {
1134        let url = MagicLink::issue(
1135            &scope(),
1136            "https://x/login",
1137            "alice@example.com",
1138            SECRET,
1139            Duration::from_secs(900),
1140        );
1141        let email = MagicLink::verify(&scope(), &url, SECRET).unwrap();
1142        assert_eq!(email, "alice@example.com");
1143    }
1144
1145    #[test]
1146    fn magic_link_rejects_password_reset_token() {
1147        let url = PasswordReset::issue(
1148            &scope(),
1149            "https://x/r",
1150            42,
1151            SECRET,
1152            Duration::from_secs(3600),
1153        );
1154        let r = MagicLink::verify(&scope(), &url, SECRET);
1155        assert!(matches!(r, Err(AuthFlowError::WrongPurpose(_))));
1156    }
1157
1158    #[cfg(feature = "cache")]
1159    #[tokio::test]
1160    async fn magic_link_single_use_rejects_replay() {
1161        use crate::cache::InMemoryCache;
1162        let cache: std::sync::Arc<dyn crate::cache::Cache> =
1163            std::sync::Arc::new(InMemoryCache::new());
1164        let url = MagicLink::issue(
1165            &scope(),
1166            "https://x/login",
1167            "alice@example.com",
1168            SECRET,
1169            Duration::from_secs(900),
1170        );
1171        // First redemption succeeds.
1172        let email = MagicLink::verify_single_use(&scope(), &url, SECRET, &cache)
1173            .await
1174            .unwrap();
1175        assert_eq!(email, "alice@example.com");
1176        // Audit N1 — replaying the same link is rejected.
1177        let replay = MagicLink::verify_single_use(&scope(), &url, SECRET, &cache).await;
1178        assert!(
1179            matches!(replay, Err(AuthFlowError::AlreadyUsed)),
1180            "{replay:?}"
1181        );
1182        // A *different* link (fresh signature) is unaffected.
1183        let other = MagicLink::issue(
1184            &scope(),
1185            "https://x/login",
1186            "bob@example.com",
1187            SECRET,
1188            Duration::from_secs(900),
1189        );
1190        assert!(
1191            MagicLink::verify_single_use(&scope(), &other, SECRET, &cache)
1192                .await
1193                .is_ok()
1194        );
1195    }
1196
1197    #[cfg(feature = "cache")]
1198    fn link() -> String {
1199        MagicLink::issue(
1200            &scope(),
1201            "https://x/login",
1202            "alice@example.com",
1203            SECRET,
1204            Duration::from_secs(900),
1205        )
1206    }
1207
1208    /// `InMemoryCache` whose `exists` waits until two callers are inside it.
1209    #[cfg(feature = "cache")]
1210    struct Racy(crate::cache::InMemoryCache, tokio::sync::Barrier);
1211
1212    #[cfg(feature = "cache")]
1213    #[async_trait::async_trait]
1214    impl crate::cache::Cache for Racy {
1215        async fn get(&self, k: &str) -> Result<Option<String>, crate::cache::CacheError> {
1216            self.0.get(k).await
1217        }
1218        async fn set(
1219            &self,
1220            k: &str,
1221            v: &str,
1222            t: Option<Duration>,
1223        ) -> Result<(), crate::cache::CacheError> {
1224            self.0.set(k, v, t).await
1225        }
1226        async fn delete(&self, k: &str) -> Result<(), crate::cache::CacheError> {
1227            self.0.delete(k).await
1228        }
1229        async fn exists(&self, k: &str) -> Result<bool, crate::cache::CacheError> {
1230            let seen = self.0.exists(k).await;
1231            self.1.wait().await;
1232            seen
1233        }
1234        async fn clear(&self) -> Result<(), crate::cache::CacheError> {
1235            self.0.clear().await
1236        }
1237        async fn add(
1238            &self,
1239            k: &str,
1240            v: &str,
1241            t: Option<Duration>,
1242        ) -> Result<bool, crate::cache::CacheError> {
1243            self.0.add(k, v, t).await
1244        }
1245    }
1246
1247    /// #1853 — two simultaneous redemptions of one link: exactly one wins.
1248    #[cfg(feature = "cache")]
1249    #[tokio::test]
1250    async fn concurrent_redemptions_of_one_link_yield_one() {
1251        let cache: std::sync::Arc<dyn crate::cache::Cache> = std::sync::Arc::new(Racy(
1252            crate::cache::InMemoryCache::new(),
1253            tokio::sync::Barrier::new(2),
1254        ));
1255        let (url, scope) = (link(), scope());
1256        let (a, b) = tokio::time::timeout(Duration::from_secs(5), async {
1257            tokio::join!(
1258                MagicLink::verify_single_use(&scope, &url, SECRET, &cache),
1259                MagicLink::verify_single_use(&scope, &url, SECRET, &cache),
1260            )
1261        })
1262        .await
1263        .expect("no deadlock");
1264        assert_eq!(u8::from(a.is_ok()) + u8::from(b.is_ok()), 1, "{a:?} {b:?}");
1265    }
1266
1267    /// #1853 — a cache that keeps nothing cannot enforce single use, so it refuses.
1268    #[cfg(feature = "cache")]
1269    #[tokio::test]
1270    async fn a_null_cache_refuses_single_use() {
1271        let cache: std::sync::Arc<dyn crate::cache::Cache> =
1272            std::sync::Arc::new(crate::cache::NullCache);
1273        let r = MagicLink::verify_single_use(&scope(), &link(), SECRET, &cache).await;
1274        assert_eq!(r, Err(AuthFlowError::AlreadyUsed));
1275    }
1276
1277    /// Accepts reads, fails every write.
1278    #[cfg(feature = "cache")]
1279    struct WriteFails;
1280
1281    #[cfg(feature = "cache")]
1282    #[async_trait::async_trait]
1283    impl crate::cache::Cache for WriteFails {
1284        async fn get(&self, _: &str) -> Result<Option<String>, crate::cache::CacheError> {
1285            Ok(None)
1286        }
1287        async fn set(
1288            &self,
1289            _: &str,
1290            _: &str,
1291            _: Option<Duration>,
1292        ) -> Result<(), crate::cache::CacheError> {
1293            Err(crate::cache::CacheError::Connection("down".into()))
1294        }
1295        async fn delete(&self, _: &str) -> Result<(), crate::cache::CacheError> {
1296            Ok(())
1297        }
1298        async fn exists(&self, _: &str) -> Result<bool, crate::cache::CacheError> {
1299            Ok(false)
1300        }
1301        async fn clear(&self) -> Result<(), crate::cache::CacheError> {
1302            Ok(())
1303        }
1304    }
1305
1306    /// #1853 — a failed marker write would leave the link reusable, so it refuses.
1307    #[cfg(feature = "cache")]
1308    #[tokio::test]
1309    async fn a_failed_marker_write_refuses() {
1310        let cache: std::sync::Arc<dyn crate::cache::Cache> = std::sync::Arc::new(WriteFails);
1311        let r = MagicLink::verify_single_use(&scope(), &link(), SECRET, &cache).await;
1312        assert_eq!(r, Err(AuthFlowError::AlreadyUsed));
1313    }
1314
1315    // -------------------------------- query string handling
1316
1317    #[test]
1318    fn extract_query_picks_right_param() {
1319        let url = "https://x/path?a=1&b=2&c=3";
1320        assert_eq!(extract_query(url, "b"), Some("2".to_owned()));
1321        assert_eq!(extract_query(url, "missing"), None);
1322    }
1323
1324    #[test]
1325    fn extract_query_handles_url_encoded_value() {
1326        let url = "https://x/path?email=alice%40x.com";
1327        assert_eq!(extract_query(url, "email"), Some("alice@x.com".to_owned()));
1328    }
1329
1330    #[test]
1331    fn missing_purpose_param_treated_as_malformed() {
1332        // Hand-crafted signed URL without purpose
1333        let url = sign(
1334            "https://x/r?user_id=42",
1335            SECRET,
1336            Some(Duration::from_secs(60)),
1337        );
1338        let r = PasswordReset::verify(&scope(), &url, SECRET);
1339        assert_eq!(r.unwrap_err(), AuthFlowError::Malformed);
1340    }
1341}