Manual contentsSecurityBrowse 126 chapters
Manual 51 min read

Auth Flows

suprnova::auth_flows is the lifecycle layer on top of authentication. Where auth::* answers "who is this request", auth_flows::* covers mailbox proof, password recovery, account lockout, and framework TOTP challenges.

Five surfaces ship under the namespace:

  • EmailVerification mints and consumes framework auth_flow_tokens, sends the link through the user's send_email_verification_notification, a notification by default, and marks the authenticated token owner verified through the configured UserProvider.
  • PasswordReset uses the installed Magnetar engine when available. Without Magnetar, verified accounts can reset through the configured UserProvider and framework auth_flow_tokens; unverified accounts fail closed because a generic provider cannot perform Magnetar's atomic first-email-proof policy.
  • BruteForce and LoginThrottleMiddleware delegate account lockout state to the installed Magnetar engine.
  • TwoFactor is the framework-owned TOTP facade over two_factor_credentials. It provides enrollment, confirmation, verification, recovery codes, secret rotation, challenge promotion, timestep replay protection, and a second-factor brute-force counter of its own in two_factor_attempts.
  • remember_me re-exports the legacy framework remember module for namespace compatibility. When Magnetar is installed, normal Auth and SessionMiddleware remember flows use Magnetar credentials instead.

Two route-gate middleware ship in the same namespace:

  • EnsureEmailVerifiedMiddleware composes after AuthMiddleware and gates routes on email_verified_at.
  • TwoFactorChallengeMiddleware composes before AuthMiddleware and redirects a session with a pending framework TOTP challenge to the challenge form.

Transactional messages always go out through the framework Mail transport, the verification link as a mail notification. Magnetar supplies security engines and storage contracts; it does not install a second application mail transport.

Where state lives

Email-verification tokens live in the framework's auth_flow_tokens table and the verified timestamp is written through the configured UserProvider. Verification is actor-bound: the current authenticated user must own the token.

Password-reset tokens, password credentials, lockout rows, opaque sessions, remember credentials, passkey ceremonies, OAuth ceremonies, and auth epochs belong to the installed Magnetar host engine. Password reset, magic link, and OAuth verified-email completion share Magnetar's atomic first-email-proof boundary for reclaiming unverified accounts.

The public TwoFactor facade in this chapter retains its framework-owned two_factor_credentials schema. Magnetar also has a factor engine used by the integrated password, magic-link, passkey, OAuth, and session flows. Do not assume the two stores are interchangeable: use one enrollment surface consistently for a given application.

Suprnova continues to own HTTP middleware, cookies, outbound mail, events, and the UserProvider bridge. Application code uses framework facades rather than calling storage engines directly.

Failure semantics across flows

Every facade follows one ordering rule: the durable state change commits first, then notification side effects fire. A listener panic, a transient mail-transport failure, or a dispatcher error after the mutation cannot roll the mutation back.

  • EmailVerification::verify requires the authenticated token owner, consumes the token, and marks the user verified before firing EmailVerified. An account that is already verified has the token consumed and keeps its verification time, and no event fires.
  • PasswordReset::complete commits through the installed Magnetar engine when available, including first-proof policy, auth-epoch advancement, and atomic revocation. The provider fallback is verified-account-only: it consumes the framework token, rotates the provider password, then reports framework session and remember revocation outcomes. When a revocation fails, it queues a retry before it returns, and returns an error when the retry cannot be queued. Mail and events run afterward.
  • BruteForce::unlock_account commits the unlock before firing AccountUnlocked.
  • TwoFactor::confirm stamps confirmed_at before firing TwoFactorEnrolled; TwoFactor::disable deletes the row before firing TwoFactorDisabled; TwoFactor::complete_challenge promotes pending → authed before dispatching the standard auth::Login + auth::Authenticated pair followed by TwoFactorChallenged.

A listener that needs durability should buffer its work (queue a job from the listener body); the facades never retry a listener. The one retry the framework queues itself is the retry of a password reset's failed revocation, described in When a revocation fails.

Bootstrapping

Initialize Magnetar after DB::init and after APP_KEY has initialized Crypt:

use suprnova::{DB, MagnetarConfig, PasskeyConfig, init_magnetar};

pub async fn register() -> Result<(), suprnova::FrameworkError> {
    let database = DB::connection()?;
    let config = MagnetarConfig::from_sea_orm(database.inner().clone())
        .passkey_config(PasskeyConfig {
            rp_id: "app.example.com".to_string(),
            rp_origin: "https://app.example.com".to_string(),
        });

    init_magnetar(config).await
}

init_magnetar creates the default auth schema unless migrations are disabled, then installs password/session and passkey adapters atomically. Calling it a second time returns an error. Tests that need process-global installation should use a dedicated integration-test binary because an installed engine is not replaceable.

Email verification

Email verification needs:

  1. A registered UserProvider that can retrieve users by email and mark the verification timestamp.
  2. MustVerifyEmail on the application user type.
  3. A nullable email_verified_at column.
  4. The framework auth_flow_tokens table.
use chrono::{DateTime, Utc};
use suprnova::MustVerifyEmail;

impl MustVerifyEmail for User {
    fn email(&self) -> &str {
        &self.email
    }

    fn email_verified_at(&self) -> Option<DateTime<Utc>> {
        self.email_verified_at
    }

    fn set_email_verified_at(&mut self, value: Option<DateTime<Utc>>) {
        self.email_verified_at = value;
    }
}

The verification handler must run inside authenticated session scope. A valid token for another user is refused with 403 and is not consumed.

Password reset and lockout

BruteForce requires the installed Magnetar password engine. Password reset prefers that engine, but a provider-backed application can reset already verified users without installing Magnetar when its UserProvider explicitly supports password reset. EloquentUserProvider<M> opts in automatically when M implements MustVerifyEmail + CanResetPassword. Unverified users receive no reset link on the provider path; install Magnetar to use password reset as an atomic first mailbox proof.

MagnetarConfig::lockout_config accepts magnetar::password::lockout::LockoutConfig. The default policy enables lockout after five failed attempts for 15 minutes, retains audit rows for seven days, and fails closed when the lockout backend is unavailable.

The same policy counts the failed codes of Magnetar's own second-factor challenge (Auth::factor()), but in a table of their own, auth_second_factor_lockouts, which default_schema::migrate creates beside auth_lockouts. Password sign-in counts against any string it's given as an address, and anyone can register any string as one, so a shared table would let an address reach a user's second-factor counter. A correct password therefore doesn't clear second-factor failures, a failed sign-in can't lock a second factor, and wrong codes don't lock password sign-in. The second factor's keys aren't addresses, so its locks and unlocks never touch an app_users row: an account registered under an address equal to a key is never locked or unlocked by it. A password reset proves the mailbox, not the second factor, so it leaves a second-factor lock in place until the window passes. Confirming an enrollment, rotating the secret, and regenerating recovery codes count their wrong codes in the same table. Each of these paths reserves its attempt before it reads the code, so parallel guesses never get past the limit, and a failure the lockout store can't record is returned as an error instead of a wrong code. A confirmation confirms only the secret its code was checked against, once, and uses the code up: if another request replaces the enrollment in between, nothing is confirmed and the caller gets a conflict. A rotation waits beside the confirmed secret in auth_two_factor's pending columns, which default_schema::migrate adds to an existing table. The confirmed secret and its recovery codes keep gating sign-in until a code from the new secret confirms the rotation, a plain enrollment can't replace a waiting rotation, and disabling the factor discards it.

Password reset normalizes an unknown or provider-backed unverified address to Ok(()) only after the abuse-limiter, mail configuration, provider/engine, and storage checks succeed. Configuration and storage failures still surface. Magnetar completion uses the atomic first-email-proof store. Provider fallback completion returns a PasswordResetOutcome with explicit framework session and remember-revocation results, and queues a retry of a revocation that failed.

Registering the 2FA migrations

The framework ships the schema; your app opts in by loading the two-factor list, auth_flows::two_factor::migrations(), next to its own migrator:

use suprnova::Application;

#[suprnova::main]
async fn main() {
    Application::new()
        .bootstrap(my_app::bootstrap::bootstrap)
        .routes(my_app::routes::register)
        .migrations::<my_app::migrations::Migrator>()
        .load_migrations_from(suprnova::auth_flows::two_factor::migrations)
        .run()
        .await
}

The list holds every two-factor migration in order: migration creates two_factor_credentials, migration_replay adds last_used_timestep for TOTP replay protection, migration_attempts creates two_factor_attempts, the second-factor brute-force counter, and migration_rotation creates two_factor_rotations, where a rotation waits for its new secret to be confirmed. A release that adds a two-factor migration adds it to the list, so your application runs it without an edit. Every migrate command runs the list; see Migrations.

An application that lists the four migrations in its own migrator by hand keeps working: a migration whose name the migrator already lists runs once.

The migrations are idempotent against an already-applied database (the v1, the attempt table and the rotation table use CREATE TABLE IF NOT EXISTS; the v2 is a column add). Re-running suprnova migrate against a production database that already has the schema is a no-op.

An application that upgrades from a release without the attempt counter runs migrate once; an application that lists the migrations by hand must add the third one. Until it runs, every TwoFactor proof path answers 503: the attempt cannot be counted, so no code is evaluated.

Environment

The transactional mailables read two environment variables at send time:

Var Default Used for
APP_NAME "Suprnova" Subject branding and the otpauth:// issuer label that authenticator apps display.
MAIL_FROM none - errors when unset Envelope From on every outgoing message. Set to a verified sender domain.
PASSWORD_RESET_TIMEBOX_MS 200 How long PasswordReset::send_link holds every answer, in milliseconds. 0 turns the hold off. A value that is not a whole number makes send_link return an error before it looks anything up.

MAIL_FROM deliberately has no default. Defaulting to a placeholder like noreply@example.com would silently break DMARC / SPF in production and ship from a domain the operator doesn't control, so the facade fails closed instead. EmailVerification::send_link and PasswordReset::send_link surface the error as Err; PasswordReset::complete logs via tracing::warn! and continues (the password change has already committed, so the notification path cannot roll it back).

Apps additionally set APP_URL so controllers can derive the base URL used in send_link calls; the framework facade itself takes the base URL as a parameter.

The mail driver is configured separately via MAIL_DRIVER - see the Mail docs.

Email Verification

EmailVerification mints, checks, and consumes verification tokens against the auth_flow_tokens table and marks the user verified through the configured provider. Four operations cover the lifecycle:

Method Signature Notes
send_link send_link<U: MustVerifyEmail>(user: &U, base_url: &str) -> Result<()> Mint the token and send the link through the user's send_email_verification_notification, given a user already in hand.
resend resend(email: &str, base_url: &str) -> Result<()> Sends through the provider's send_email_verification_notification. Normalizes an unknown provider result to Ok(()); token storage and mail failures still return Err, and execution time is not equalized.
check check(token: &str) -> Result<bool> Non-consuming - safe to call on a landing page.
verify verify(token: &str) -> Result<String> Actor-bound and single-use: the authenticated user must own the token; success consumes it, marks the user verified, and returns that user ID. Another account's token gets 403; an unknown, expired or used token gets 400.
use suprnova::auth_flows::EmailVerification;

// After a fresh signup, with the freshly-created user in hand:
EmailVerification::send_link(&user, "https://app.example.com/verify-email").await?;

// Optional landing-page check - non-consuming, so a page refresh
// does not burn the token.
let valid: bool = EmailVerification::check(&token_str).await?;

// The click-through handler runs behind authentication. `verify`
// consumes the token only when `Auth::id()` matches its owner.
let user_id: String = EmailVerification::verify(&token_str).await?;

verify fires EmailVerified on success - listeners are the right place to unlock additional functionality (welcome email, default follows, "complete your profile" CTA) without coupling them to the verification handler. The event carries the provider's user id. When the provider's is_email_verified already reports the account verified, verify consumes the token and returns the user ID without a new stamp and without the event, so a second link never moves the first verification time.

Sending your own verification message

send_link mints the token, builds the link, and passes it to the user's MustVerifyEmail::send_email_verification_notification. The default sends the framework's VerifyEmailNotification to email() as an on-demand mail notification through Notify. It goes through the mail channel of the dispatcher you bound with notifications::set_dispatcher, or through the framework's mail channel when you bound none, so verification mail needs no notification setup.

Override the method to send your own message. The framework then sends nothing itself, and the link you receive carries the same single-use token, so verify accepts it:

use chrono::{DateTime, Utc};
use suprnova::{FrameworkError, Mail, MustVerifyEmail};

impl MustVerifyEmail for User {
    fn email(&self) -> &str {
        &self.email
    }

    fn email_verified_at(&self) -> Option<DateTime<Utc>> {
        self.email_verified_at
    }

    fn set_email_verified_at(&mut self, value: Option<DateTime<Utc>>) {
        self.email_verified_at = value;
    }

    async fn send_email_verification_notification(
        &self,
        verification_link: &str,
    ) -> Result<(), FrameworkError> {
        Mail::raw(format!("Confirm your address: {verification_link}"), |mail| {
            mail.to(self.email.as_str()).subject("Confirm your address")
        })
        .await
    }
}

resend holds only the ID the provider returned, so it sends through UserProvider::send_email_verification_notification(id, verification_link). EloquentUserProvider loads the model and calls the model's method, so your override runs on both paths. The provider default sends the framework's notification to verification_email(id), greeting the name of flow_user_by_id(id), and returns an error when the provider reports no verification address.

resend binds the link to the provider's verification_email(id), not to the address it looked the account up by. For EloquentUserProvider that is your model's email(), the address send_link binds and verify checks. A model whose email() reads a column other than the email column resend looks up by therefore gets a link that verify accepts. A provider that reports no verification address keeps the lookup address, and its send_email_verification_notification decides the outcome.

The resend endpoint (anti-enumeration)

resend takes only the email and looks up the user through the active provider. An unknown provider result is normalized to Ok(()). For a known account, the facade mints a token and sends the mail. EmailVerification::resend likewise normalizes an unknown provider result to Ok(()); it does not guarantee identical timing or identical behavior when token storage or mail delivery fails. A handler can still return a neutral message after either successful result:

use std::collections::HashMap;
use suprnova::auth_flows::EmailVerification;
use suprnova::{FrameworkError, HttpResponse, Request, Response};

pub async fn resend(req: Request) -> Response {
    resend_inner(req).await.map_err(HttpResponse::from)
}

async fn resend_inner(req: Request) -> Result<HttpResponse, FrameworkError> {
    let raw = req.query().unwrap_or("");
    let params: HashMap<String, String> =
        url::form_urlencoded::parse(raw.as_bytes()).into_owned().collect();
    let email = params
        .get("email")
        .ok_or_else(|| FrameworkError::bad_request("missing email"))?;

    let base = format!(
        "{}/auth/verify",
        std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8765".into()),
    );
    // `resend` performs the lookup and normalizes an unknown address to `Ok(())`.
    EmailVerification::resend(email, &base).await?;

    Ok(HttpResponse::text(
        "If this email is on file, a verification link has been sent.",
    ))
}

send_link and resend both build the URL as {base_url}?token={plaintext_token}. A trailing slash on base_url is trimmed before the query string is appended, so https://app.example.com/verify/ and https://app.example.com/verify both produce a clean URL.

The click-through handler must run behind AuthMiddleware. It pulls the token from the query string and calls verify:

async fn verify_inner(req: Request) -> Result<HttpResponse, FrameworkError> {
    let raw = req.query().unwrap_or("");
    let params: HashMap<String, String> =
        url::form_urlencoded::parse(raw.as_bytes()).into_owned().collect();
    let token = params
        .get("token")
        .ok_or_else(|| FrameworkError::bad_request("missing token"))?;

    let _user_id = EmailVerification::verify(token).await?;
    Ok(HttpResponse::new().status(302).header("Location", "/"))
}

verify checks the route's user against the token owner before consumption. The route's user is the user of the guard the last AuthMiddleware checked, or of the default guard, and verify reads and stamps it through that guard's provider; behind AuthMiddleware::for_guard("admin") it is the admin user, never the default guard's user in the same session. A live token belonging to another account returns 403 with the message This action is unauthorized. and remains unused. An unknown, expired or used token returns 400 with the message invalid or expired verification token. On success, the provider marks the authenticated owner verified and the facade fires EmailVerified.

A link proves the mailbox it was sent to, and no other. The token carries a digest of that address, and verify compares it with the account's current verification address, which the provider's verification_email reports. When the account changed its address after the link was sent, verify returns 403 This action is unauthorized., leaves the token unused, and marks nothing verified. The provider stamps the verification through mark_email_verified_for, which writes only while the address is still the one the link was sent to, so a change that lands while verify runs is refused too; the token is spent by then, and the user asks for a new link. A link sent before an upgrade to this behavior carries no address and is refused the same way, so the user asks for a new one.

EloquentUserProvider reports the MustVerifyEmail address, and rereads it under a row lock in the same transaction as the stamp. A custom provider reports the email of flow_user_by_id unless it implements verification_email.

A custom provider that does not implement mark_email_verified_for leaves a window. The default reads the address with verification_email, compares it, and then calls mark_email_verified: two separate storage operations. An address change that commits between them is marked verified without proof of the new mailbox. To close the window, implement mark_email_verified_for itself, not mark_email_verified, so that the check and the write are one storage operation: a conditional write such as UPDATE users SET email_verified_at = ? WHERE id = ? AND email = ? that reports whether it matched a row, or a reread under a row lock (SELECT ... FOR UPDATE) in the same transaction as the write. It returns Ok(false), and writes nothing, when the address is no longer the one it was given.

Verified-only routes: EnsureEmailVerifiedMiddleware

EnsureEmailVerifiedMiddleware gates routes on the authenticated user's email_verified_at. Compose it after AuthMiddleware and the chain blocks any request whose user has not yet completed the verify step.

The user it checks is the user of the route's guard: the guard the last AuthMiddleware to pass the request on checked, or the default guard when none names one. It asks that guard's provider. Behind AuthMiddleware::new().for_guard("admin"), the gate checks the admin user through the admin guard's provider, and a verified default-guard user signed in on the same session does not pass it.

The middleware decides each answer from the request:

  • A request that expects JSON (Request::expects_json) gets 403 with {"message": "Your email address is not verified."}, even when the middleware names a redirect.
  • Any other request gets the redirect the middleware names: 302, or 409 with X-Inertia-Location for an Inertia visit. The redirect goes through Redirect::guest, so the intended URL is stored by its rule: a GET stores its own path and query, any other request stores the page the user was on. After verification, Redirect::intended sends the user back.
  • With no redirect named, as with new(), every caller gets the 403.

Name the redirect by path with redirect_to(path) or by route with redirect_to_route(name). The route name is resolved on each request through routing::try_route, so the redirect follows the route's path and public root. A name no route carries fails the request with a 500 whose error names the route, and so does a route whose path needs parameters.

use suprnova::{AuthMiddleware, EnsureEmailVerifiedMiddleware, group, get};

// API surface - 403 with a JSON body.
group!("/api")
    .middleware(AuthMiddleware::new())
    .middleware(EnsureEmailVerifiedMiddleware::new())
    .routes([
        get!("/me", profile::show),
    ]);

// Web surface - 302 to the route's path (or 409 + X-Inertia-Location for
// Inertia visits), 403 JSON for a request that expects JSON.
group!("/dashboard")
    .middleware(AuthMiddleware::redirect_to("/login"))
    .middleware(EnsureEmailVerifiedMiddleware::redirect_to_route("verification.notice"))
    .routes([
        get!("/", dashboard::index),
    ]);

If no user is authenticated, the middleware falls into the same response branch as "authed but not verified" - matching Laravel's ! $request->user() || ! hasVerifiedEmail() shape. Compose AuthMiddleware first when you want a separate 401 for unauthed requests.

For in-handler branching (e.g. conditionally rendering a "please verify" CTA without redirecting), load the typed user through the session guard and read the trait method:

use suprnova::{Auth, MustVerifyEmail};
use crate::models::users::User;

if let Some(user) = Auth::user_as::<User>().await? {
    let verified: bool = user.is_email_verified();
    // branch on it
}

Why Suprnova diverges

Laravel's EnsureEmailIsVerified redirects to the verification.notice route when no route is named. EnsureEmailVerifiedMiddleware::new() names no redirect and answers 403 to every caller: the framework registers no verification page, and a route name nobody registered would fail every request. An application made with suprnova new names its page verification.notice, so redirect_to_route("verification.notice") gives Laravel's behavior there.

Laravel's verification link is a signed URL that carries the user's ID and a hash of the address. Suprnova's link carries a single-use token stored in auth_flow_tokens and bound to the address. A used link cannot be replayed, which a signed URL allows until it expires. verify answers the checks a signed URL makes in Laravel's EmailVerificationRequest, another account's link and a changed address, with the same 403.

Password Reset

PasswordReset has four operations:

Method Signature Notes
send_link send_link(email: &str, base_url: &str) -> Result<()> Uses Magnetar when installed; otherwise issues a framework token only for a verified user from an explicitly reset-capable provider and sends the link through the provider's send_password_reset_notification. Unknown and unverified addresses return Ok(()). Every answer is held for PASSWORD_RESET_TIMEBOX_MS.
check check(token: &str) -> Result<bool> Non-consuming validation through Magnetar or the framework token store used by the provider fallback.
complete complete(token: &str, new_password: &str) -> Result<String> Runs Magnetar's atomic first-proof transaction when installed; otherwise rotates a verified provider user, revokes framework session/remember state, and queues a retry of a revocation that failed.
complete_with_outcome complete_with_outcome(token, new_password) -> Result<PasswordResetOutcome> Returns committed Magnetar counts or the provider fallback's explicit framework revocation outcomes, and says whether a retry was queued.
use suprnova::auth_flows::PasswordReset;

// From the "forgot password" form. An unknown address returns `Ok(())`
// after prerequisite checks succeed; configuration and backend errors still surface.
PasswordReset::send_link(&email, "https://app.example.com/reset").await?;

// Optional landing-page check before rendering the new-password form.
let valid: bool = PasswordReset::check(&token).await?;

// The click-through handler, after the user submits a new password:
// consume the token, rotate the password, and revoke the user's other
// sessions and remember-me tokens. Report success only when both
// revocations succeeded: a stolen session still works until they do.
let outcome = PasswordReset::complete_with_outcome(&token, &new_password).await?;
outcome.sessions_revoked?;
outcome.remember_tokens_revoked?;
let user_id: String = outcome.user_id;

complete returns the user id alone and discards the two revocation outcomes, so a handler that calls it reports success while a stolen session may still work; prefer complete_with_outcome. Both queue the retry of a failed revocation, as When a revocation fails describes. Both pass the plaintext password through SecretString; Magnetar hashes it inside the credential engine. Do not pre-hash it. An empty or whitespace-only password returns HTTP 400 before the engine is called.

Sending your own reset message

On the provider path, send_link mints the token, builds the link, and passes it to UserProvider::send_password_reset_notification(id, reset_link). EloquentUserProvider loads the model and calls its CanResetPassword::send_password_reset_notification. The default sends the framework's PasswordResetMail to email_for_reset(), which stays the address your model chooses for reset mail. The default mail greets the reader without a name, because CanResetPassword has none to read.

Override the method to send your own message; the framework then sends nothing itself, and complete accepts the link you receive:

use suprnova::{CanResetPassword, FrameworkError, Mail};

impl CanResetPassword for User {
    fn email_for_reset(&self) -> &str {
        &self.email
    }

    fn set_password_hash(&mut self, hash: &str) {
        self.password = hash.to_string();
    }

    async fn send_password_reset_notification(
        &self,
        reset_link: &str,
    ) -> Result<(), FrameworkError> {
        Mail::raw(format!("Choose a new password: {reset_link}"), |mail| {
            mail.to(self.email.as_str()).subject("Your password reset link")
        })
        .await
    }
}

A custom reset-capable provider keeps the provider default, which sends the framework's mail to the address flow_user_by_id(id) returns and greets its name, or overrides send_password_reset_notification. The default returns an error when flow_user_by_id finds no user. The Magnetar path holds an address, not a model, and always sends the framework's mail.

Bounded anti-enumeration behavior

PasswordReset::send_link returns Ok(()) for an unknown address only after the abuse-limiter, mail configuration, engine, and storage checks succeed. Configuration, limiter, storage, and mail failures still return Err. The dogfood controller gives successful known- and unknown-account requests the same HTTP status and body.

send_link also holds every answer until PASSWORD_RESET_TIMEBOX_MS milliseconds have passed since the call began, 200 by default as Laravel's auth.timebox_duration is: a known address and an unknown one, the abuse refusal and every error. An unknown address therefore answers no sooner than a known one, unless the work for the known one takes longer than the timebox. Raise the value if your mail transport is slower than that:

PASSWORD_RESET_TIMEBOX_MS=500

Why Suprnova diverges

Laravel's broker answers an unknown address with the INVALID_USER status. send_link answers Ok(()) for it, as for a sent link, so a handler cannot show a different message by mistake. email_for_reset has no default, unlike Laravel's getEmailForPasswordReset, because the trait has no address field to fall back to.

complete side effects

Magnetar commits password reset in one transaction:

  1. Consume the single-use reset token.
  2. Apply first-email-proof policy when the account is still unverified.
  3. Hash and replace the password.
  4. Advance the authentication epoch.
  5. Revoke old opaque sessions and remember credentials.
  6. Remove provisional credentials when this reset is the account's first mailbox proof.

After commit, the framework sends PasswordChangedMail and dispatches PasswordResetCompleted. Mail or listener failure cannot roll back the reset.

On an already verified account, reset preserves legitimate passkeys, linked accounts, and confirmed two-factor enrollment. On an unverified squatted account, first proof removes provisional credentials so the prior registrant cannot retain access.

When a revocation fails

On the provider path, the reset writes the password before it revokes the user's other sessions and remember-me tokens, and the reset token is spent by then. A revocation that fails can't undo either step, so the reset reports the failure and makes the revocation durable instead:

  • complete_with_outcome returns the store's error in sessions_revoked or remember_tokens_revoked. Your handler answers an error, never the success page. The controller that suprnova new writes returns that error. The dogfood application answers 500 and logs the failure at error level with the user's id.
  • Before it returns, the reset pushes a RevokeUserCredentials job for the user with Queue::push. complete pushes it too. The outcome's revocation_retry field is then RevocationRetry::Queued. RevocationRetry::NotNeeded means both revocations succeeded.
  • When the push fails too, nothing would retry the revocations, so complete_with_outcome and complete return an error instead of the outcome. The error says that the user's password changed, names each revocation that failed with the store's error, gives the push's error, and asks for RevokeUserCredentials::new("<id>") to be queued once the queue works. The framework logs it at error level with the user's id, and sends no PasswordChangedMail and no PasswordResetCompleted for that reset. Your handler's ? answers it as a 500.
  • A worker running queue:work runs the job. Each run revokes both the sessions and the remember-me tokens, fails while either store refuses, and succeeds once both confirm. A run after the credentials are gone revokes nothing and succeeds, so running the job again is always safe. It revokes every session and remember-me token of the user, those opened after the reset too, so a retry that succeeds later signs the user out once more; the error answer already told them to sign in again on every device.
  • Until the job succeeds, the stale credentials still don't work. Each sign-in records the user's authentication epoch, which a password change moves. On every request, the framework compares the recorded epoch with the current one, so a session or remember-me cookie from before the reset is signed out and answered as a guest. A user whose provider exposes neither an epoch nor a password hash records no epoch, so for that user the job is the only remedy.

With Magnetar installed, the revocations commit in the reset's own transaction, so there is nothing to retry and revocation_retry is RevocationRetry::NotNeeded.

The job retries until both stores confirm, with no bound on the number of tries: it declares zero tries, which the queue reads as unlimited. The first retry waits about 10 seconds and each later one twice as long, up to one hour between tries, for as long as a store refuses. Each try has a 60-second timeout, and a try that times out is retried too. The job reaches failed_jobs only when the worker's --tries bounds it, or when a try returns an error that ends retries, as Errors that end retries describes. queue:retry then runs it again. The framework registers the job with the worker, so it needs no register_job call.

The retry is only as durable as the queue that holds it:

  • The database and redis drivers keep the job until a worker runs it.
  • The memory driver keeps it in the web process, so a restart loses it.
  • The null driver discards it, so nothing retries.
  • The sync driver runs the job inside the push. When both stores confirm on that run, the revocations are done. When a store still refuses, the push fails and the reset returns the error described above.

A handler that reads the outcome logs the queued retry with the user's id. A failed push never reaches it as an outcome; ? returns that error:

use suprnova::FrameworkError;
use suprnova::auth_flows::PasswordReset;

// A retry that could not be queued is an `Err` here.
let outcome = PasswordReset::complete_with_outcome(&token, &new_password).await?;
if outcome.sessions_revoked.is_err() || outcome.remember_tokens_revoked.is_err() {
    // `outcome.revocation_retry` is `RevocationRetry::Queued`.
    tracing::warn!(user_id = %outcome.user_id, "queued a retry of the revocations");
    return Err(FrameworkError::internal(
        "Your password was changed, but signing you out of your other devices failed.",
    ));
}

After a failed push, queue the job yourself once the queue works again, for example from an operator command, with the id the error names:

use suprnova::Queue;
use suprnova::auth_flows::RevokeUserCredentials;

Queue::push(RevokeUserCredentials::new(user_id)).await?;

Queue the same job after any other credential change whose revocation failed.

Brute-Force Protection

The brute-force layer has two parts: the BruteForce facade that records and queries lockout state, and the LoginThrottleMiddleware that short-circuits at the HTTP layer before the handler is invoked.

The BruteForce facade

Call record_failed_attempt from the failed-auth branch of your login handler, and reset_attempts from the success branch:

use suprnova::auth_flows::BruteForce;

// In the failed-auth path:
let status = BruteForce::record_failed_attempt(&email, Some(&peer_ip)).await?;
if status.is_locked {
    // Optionally surface a custom response. The middleware will do
    // this for you on the *next* request - see below.
}

// In the success path:
BruteForce::reset_attempts(&email).await?;

record_failed_attempt returns the updated LockoutStatus (is_locked, failed_attempts, and locked_until when locked). Pass the optional ip for audit logs; pass None if your transport doesn't surface a client IP cleanly.

Two additional operations:

// Read-only - safe on emails with no history.
let status = BruteForce::get_lockout_status(&email).await?;
let locked: bool = BruteForce::is_locked(&email).await?;

// Admin / forced unlock. Fires `AccountUnlocked` only on a real state
// transition (no-op unlock on an already-unlocked account does not fire).
let was_locked: bool = BruteForce::unlock_account(&email).await?;

unlock_account returns true when the account had been locked at the time of the call, false otherwise. The AccountUnlocked event fires only on true - a false return is the no-op it is, not an audit event.

LoginThrottleMiddleware

The middleware reads the lockout state for whichever email a request is targeting and short-circuits with 429 Too Many Requests when the account is locked. The login handler is never invoked, so a locked account does not even get to attempt a credentials check:

use suprnova::auth_flows::LoginThrottleMiddleware;
use suprnova::Router;

// The email extractor is a sync closure over `&Request`. Reading
// JSON/form body is async and consumes `Request`, so the closure
// cannot read the body - pull from a header, query string, or
// route param instead.
let throttle = LoginThrottleMiddleware::new(|req| {
    req.header("X-Login-Email").map(str::to_string)
});

let router = Router::new()
    .post("/login", login_handler)
    .middleware(throttle);

Practical extraction surfaces:

  • A header (X-Login-Email), set by a preceding pre-processor - the pattern used in the dogfood app.
  • A query string parameter (?email=…).
  • A route parameter (/login/{email}).

Returning None from the extractor is the explicit "I have nothing to check" signal - the middleware passes the request through unchanged. This makes the middleware safe to install on routes that occasionally see anonymous traffic (e.g. the same POST /login endpoint that also handles a no-email "request password reset" sub-action).

On lock the middleware returns:

  • Status 429 Too Many Requests.
  • Retry-After header - seconds, computed from the lockout's locked_until via LockoutStatus::retry_after_seconds. Falls back to 900 (15 minutes, Magnetar's default lockout period) if the timestamp is somehow absent.
  • Body: "Account locked due to too many failed login attempts. Try again later."

Backend errors (fail closed by default)

If get_lockout_status returns an error, LoginThrottleMiddleware logs the failure and, by default, returns HTTP 503 Service Unavailable with Retry-After: 1 without invoking the login handler. To keep login available during a lockout-backend outage, opt in explicitly with .on_backend_error(BackendErrorPolicy::FailOpen); only that policy passes the request to the handler.

Layering with RateLimitMiddleware

LoginThrottleMiddleware is per-account - it gates a single email when the threshold is crossed. For per-IP quotas, layer it with RateLimitMiddleware::ip_based. It counts requests for each client address, using the rate limiter your application installed. The two compose naturally:

use std::time::Duration;
use suprnova::auth_flows::LoginThrottleMiddleware;
use suprnova::rate_limit::RateLimitMiddleware;
use suprnova::Router;

let router = Router::new()
    .post("/login", login_handler)
    .middleware(LoginThrottleMiddleware::new(|req| { /* ... */ }))
    .middleware(RateLimitMiddleware::ip_based(20, Duration::from_secs(60)));

Together they cover the realistic shapes of credential stuffing: distributed (one email × many IPs) is the rate limit's job; focused (many attempts × one email) is the throttle middleware's job.

Configuration

MagnetarConfig accepts a LockoutConfig. The default is five failed attempts, a 15-minute counting and lockout period, seven-day attempt retention, and BackendErrorPolicy::FailClosed:

let config = MagnetarConfig::from_sea_orm(database)
    .lockout_config(lockout_policy);

Use LockoutConfig::disabled() only when another fail-closed identity control replaces account lockout.

Two-Factor (TOTP)

TwoFactor covers TOTP-based 2FA - the kind that pairs with any standards-compliant authenticator app (Google Authenticator, 1Password, Bitwarden, Authy). The flow is enrollment → confirmation → ongoing verification, plus single-use recovery codes for when the user loses their device, plus the challenge flow that stitches everything into the login lifecycle.

The TwoFactorUser trait

The framework cannot reach into your application's user storage, so callers implement a small trait to bridge from their user model to the 2FA facade:

use suprnova::auth_flows::TwoFactorUser;

pub trait TwoFactorUser: Send + Sync {
    fn user_id(&self) -> &str;
    fn email(&self) -> &str;
}

user_id is an opaque storage key. It can be a numeric application ID rendered as text, a UUID, or a Magnetar UserId. The framework TOTP table has no foreign key to the application user table.

email is folded into the otpauth:// URL's account_name segment so the authenticator app displays a recognizable account label.

use suprnova::auth_flows::TwoFactorUser;

struct AppUser2fa<'a> {
    user: &'a User,
}

impl TwoFactorUser for AppUser2fa<'_> {
    fn user_id(&self) -> &str {
        &self.user.auth_id
    }

    fn email(&self) -> &str {
        &self.user.email
    }
}

Storage

2FA state lives in the framework-owned two_factor_credentials table. Secrets and recovery codes are encrypted at rest with crate::crypto::Crypt::encrypt_string, which requires a process-global EncryptionKey. Apps opt into the schema by listing the four migrations in their Migrator::migrations() - see Bootstrapping. The table is keyed by user_id, a TEXT column on PostgreSQL and SQLite. MySQL and MariaDB can't index a TEXT key, so there the column is VARCHAR(255). The attempt and rotation tables key the user id in 255 characters on every engine, so enroll refuses a longer id with 422 on every engine.

Enroll, confirm, verify

use suprnova::auth_flows::{TwoFactor, EnrollmentResponse};

// 1. Enrollment: generate a fresh secret + 10 recovery codes, persist
//    them encrypted, return everything needed to render the QR code.
let response: EnrollmentResponse = TwoFactor::enroll(&user_2fa).await?;
// response.otpauth_url - `otpauth://totp/...` deep link
// response.qr_code_svg - <svg> wrapping a base64 PNG, embed inline
// response.recovery_codes - Vec<String>, 10 plaintext codes - show ONCE

// 2. Confirm: the user opens the authenticator app and types in the
//    6-digit code. `confirm` validates it and stamps `confirmed_at`.
TwoFactor::confirm(&user_2fa, &user_typed_code).await?;
// fires `TwoFactorEnrolled`

// 3. On subsequent logins, gate the session on `verify`:
let ok: bool = TwoFactor::verify(&user_2fa, &code_from_login_form).await?;
if !ok {
    return Err(suprnova::FrameworkError::domain("invalid 2FA code", 401));
}

Every method that checks a code or a recovery code - verify, consume_recovery_code, confirm, re_enroll, regenerate_recovery_codes and complete_challenge - reserves one attempt in the second-factor counter before it reads the code. A wrong code turns the reservation into a failure. Five failures inside fifteen minutes (both configurable, see below) lock the second factor: every one of those methods then returns 429 Too Many Requests without evaluating the code, so the right code cannot open a locked account either. A correct code clears the failures, and parallel guesses cannot all pass one status read. When the counter cannot record an attempt, the methods answer 503 rather than evaluate the code unthrottled.

The counter is the framework's two_factor_attempts table, keyed by the user id. It is separate from the per-email password lockout that BruteForce and LoginThrottleMiddleware use: a successful password check does not clear second-factor failures, and the lock works with no Magnetar engine installed. complete_challenge is the exception that needs the engine: it resolves the pending user, and returns it, through Magnetar. A wrong code fires AccountLocked once, on the failure that sets the lock. TwoFactor::unlock(&user) clears the counter early and fires AccountUnlocked when a lock was in effect.

Two settings shape the lock:

Var Default Meaning
TWO_FACTOR_MAX_ATTEMPTS 5 Failures inside the window that lock the second factor.
TWO_FACTOR_LOCKOUT_MINUTES 15 How long each attempt counts. The window slides: a failure stops counting this long after it happened, so a lock lifts once the oldest failure still counted is this old.

Both must be whole numbers of at least 1, and the window at most 43200 minutes (thirty days): a longer window reaches back past the timestamp range MySQL and MariaDB store. Config::init checks them at boot, so a zero, malformed or too-long value stops the app with the variable named. To set them in code instead, bind a TwoFactorLockout, which wins over the environment:

use suprnova::App;
use suprnova::auth_flows::TwoFactorLockout;

App::singleton(TwoFactorLockout::new(3, 30)?);

enroll returns plaintext recovery codes exactly once. There is no API to retrieve them later - the encrypted column is one-way from this point on. Show them on the enrollment success page, encourage the user to save them, and don't store the plaintext anywhere else.

enroll refuses to overwrite a confirmed enrollment - it returns a 409 to push the caller toward re_enroll, which requires proof of possession. Re-enrolling on an unconfirmed (pending) row is allowed: the prior enrollment never became authoritative.

Both writes are conditional on what was read, so concurrent requests on one account cannot cross. confirm stamps only the enrollment whose secret the code was checked against: if a second enroll replaced the secret in between, confirm returns 409 and the new secret stays unconfirmed. A confirmation stamps an enrollment once: a second confirmation racing it gets 409 and fires no second TwoFactorEnrolled. It also uses its code up the way a successful verify does, so the code that confirmed the enrollment is refused at the next sign-in; the user signs in with a later code. enroll checks that the row is still unconfirmed in the statement that writes it, so a confirmation that lands while it runs also gets the 409.

One second factor per account

With Magnetar installed, an account holds the framework's TOTP or a Magnetar second factor, never both. Each refuses the sign-ins that don't verify it: the framework's logins refuse an account with a Magnetar factor, and Magnetar's own sign-ins refuse an account with the framework's TOTP. An account with both could sign in by no path, so neither system lets the second one in.

  • TwoFactor::enroll and TwoFactor::confirm return 409 while the account has a Magnetar factor, confirmed or waiting for its confirmation. confirm checks before it reads the code, so of two enrollments racing each other the second to confirm loses.
  • Magnetar's TwoFactorService returns a conflict on magnetar::two_factor::OTHER_SECOND_FACTOR from enroll and confirm while the account has the framework's TOTP, confirmed or pending. init_magnetar gives its service that check; a host that builds its own service passes suprnova::magnetar_integration::engine::FrameworkTotpEnrollment to TwoFactorService::with_other_second_factor.
  • Two enrollments can race, one in each system, each checking before the other writes. So each enroll asks again once its own enrollment is stored, and withdraws it with the same 409 or conflict when the other factor is there. Of two enrollments that both stored one, at least one sees the other and withdraws, so the account keeps one, and at worst both answer 409 and the user enrolls again. A Magnetar TwoFactorStore withdraws through withdraw_enrollment; the default implementation removes nothing, so a host's own store implements it to take part.

An account can still hold both if it enrolled before these checks, or if a migration imported a Magnetar factor for an account that has the framework's TOTP. Every sign-in path refuses it with 409 until one factor is disabled, and disabling either one is the recovery. The user can't sign in to do it, so it's an administrator's action:

// Disable the framework TOTP. Auth::password() and Magnetar's other
// sign-ins then ask for the Magnetar factor.
TwoFactor::disable(&user_2fa).await?;

A host that holds its TwoFactorService can disable the Magnetar factor instead. The account then signs in through the application's login and TwoFactor::complete_challenge.

Replay protection

verify writes the current TOTP timestep to last_used_timestep on success. Subsequent verifies where current_timestep <= last_used_timestep are rejected even when the code itself is structurally valid, defeating a stolen-code replay inside the 30-second window.

The timestep claim is atomic. The stamp lands via a conditional UPDATE … WHERE last_used_timestep IS NULL OR last_used_timestep < :current, and the verify only succeeds when the statement affects exactly one row. Two concurrent verifies cannot both win, even when they straddle a 30-second boundary: the first flips the column, the second's predicate no longer matches, and the second is treated as a replay. A plain read-modify-write would be a TOCTOU race - both verifies read the pre-stamp row, both validate the same code, both stamp, both succeed. Concurrent racers are also counted as failed attempts so the second-factor counter records them.

Recovery codes

let consumed: bool = TwoFactor::consume_recovery_code(&user_2fa, &code).await?;

Single-use: a matching code is removed from the row before the call returns, so a second attempt against the same code returns false. Codes are 12 decimal digits in NNNNNN-NNNNNN shape (~40 bits of entropy each, matching Laravel Fortify's format).

consume_recovery_code only accepts codes when 2FA is fully confirmed - it short-circuits to Ok(false) while confirmed_at is NULL. Without this gate, an attacker who triggered enrollment on a victim account (or any flow that creates the row without confirming) could authenticate using only a fresh recovery code, bypassing TOTP entirely. The contract is symmetric with verify's "confirmed enrollment only" guard.

Rotating recovery codes and secrets

When a user exhausts their recovery codes, or wants to rotate them after a suspected compromise:

let fresh: Vec<String> = TwoFactor::regenerate_recovery_codes(&user_2fa, &proof).await?;

proof must validate as either a current TOTP code or an unused recovery code. Without the proof check, a session-hijacked attacker could silently blow away the legitimate user's recovery codes (denial-of-service against account recovery). The fresh codes replace the persisted set; the existing secret and confirmed_at are preserved, so the user's authenticator app keeps working without re-pairing. Errors:

  • 400 - no confirmed enrollment exists; call enroll/confirm first.
  • 401 - proof validates as neither a TOTP code nor an unused recovery code.
  • 429 - wrong codes have locked the second factor.

To rotate the secret (re-pair to a new device) without disabling 2FA first:

let response = TwoFactor::re_enroll(&user_2fa, &proof).await?;

Same proof model as regenerate_recovery_codes. A fresh secret + 10 fresh recovery codes wait as a pending rotation in two_factor_rotations until the user calls confirm with a code from the new authenticator. Until then the confirmed secret and its recovery codes keep gating sign-in, so a rotation nobody finishes never turns 2FA off, and enroll, which takes no proof, can't replace the pending secret. Confirming the rotation swaps in the new secret and recovery codes in one transaction.

Disable

TwoFactor::disable(&user_2fa).await?;
// fires `TwoFactorDisabled` only if a row was removed

Idempotent: a disable on a user who never enrolled is not an error. The TwoFactorDisabled event fires only on a real state transition, so audit listeners see one entry per actual disable rather than one per click on a no-op button.

Challenge flow (gating login on the second factor)

The enroll / confirm / verify primitives are the building blocks; the challenge flow stitches them into the login lifecycle so a user with 2FA enabled cannot reach protected pages on password alone.

The flow:

  1. Password login resolves a user.
  2. If TwoFactor::is_enabled_by_id(&user_id) returns true, the login handler calls TwoFactor::start_challenge(user_id, remember) - that stashes the user-id as pending in the session, clears the fully-authenticated slot, revokes any remember-me cookie issued by Auth::attempt, and remembers whether the user opted into remember-me so the cookie can be re-issued after the challenge completes. Auth::id() returns None from this point until the challenge completes.
  3. The handler redirects to a /two-factor-challenge route that shows the code form.
  4. The challenge POST handler calls TwoFactor::complete_challenge(code) - verifies the code (TOTP or an unused recovery code, matching Fortify's challenge controller), promotes pending → authed, rotates the session id (defeating session fixation) and the CSRF token, re-issues the remember-me cookie when the user opted in, and dispatches the standard auth::Login + auth::Authenticated lifecycle events plus the 2FA-specific TwoFactorChallenged.
use suprnova::auth_flows::TwoFactor;
use suprnova::{Auth, Authenticatable, Credentials, redirect};

pub async fn login(form: LoginRequest) -> Response {
    match Auth::attempt(&Credentials::password(&form.email, &form.password), form.remember).await? {
        Some(user) => {
            let user_id = user.get_auth_identifier();
            if TwoFactor::is_enabled_by_id(&user_id).await? {
                // Demote to "pending": auth slot cleared, pending set,
                // remember-me cookie revoked. Pass through the form's
                // remember flag so `complete_challenge` can re-issue
                // the cookie on success.
                TwoFactor::start_challenge(user_id, form.remember).await?;
                redirect!("/two-factor-challenge").into()
            } else {
                redirect!("/dashboard").into()
            }
        }
        None => Err(invalid_credentials().into()),
    }
}

pub async fn complete(form: TwoFactorChallengeRequest) -> Response {
    let _user = TwoFactor::complete_challenge(&form.code).await?;
    // Session id + CSRF have rotated; remember-me has been re-issued
    // if the original login form set it. Listeners that hook
    // `auth::Login` / `auth::Authenticated` saw a normal login.
    redirect!("/dashboard").into()
}

complete_challenge rotates the session id and CSRF token as part of the promotion to authed. That closes the classic session-fixation attack where an attacker plants a known session id on a victim before they log in - after the rotation, the planted id is dead and only the freshly-generated id carries the authenticated state. The contract matches Auth::login_id / Auth::login_using_id, so 2FA logins are indistinguishable from no-2FA logins in terms of session state and listener observability. With the Magnetar engine installed, the promoted login is backed by a Magnetar session like every other web login - see Framework logins under the engine.

Gate every protected route group with TwoFactorChallengeMiddleware before AuthMiddleware so a pending session is bounced to the challenge page rather than the login page:

use suprnova::{AuthMiddleware, TwoFactorChallengeMiddleware, group, get};

group!("/dashboard")
    .middleware(TwoFactorChallengeMiddleware::redirect_to("/two-factor-challenge"))
    .middleware(AuthMiddleware::redirect_to("/login"))
    .routes([
        get!("/", dashboard::index),
    ]);

The challenge page itself (the GET that renders the form, the POST that calls complete_challenge) must NOT install TwoFactorChallengeMiddleware - it is the destination. The POST handler typically also checks TwoFactor::pending_user_id().is_some() up front so a stale link does not reach the verify logic with an empty session.

TwoFactor::cancel_challenge() clears both pending slots without authenticating anyone - wire it to a "back to login" link on the challenge page.

Recovery code fallback. complete_challenge(code) tries the TOTP path first and falls back to consuming a recovery code, so a user who lost their authenticator can still get in. Each recovery code is single-use.

Brute-force linkage. Failed challenge codes feed the second-factor counter, the same one bare TwoFactor::verify uses. An attacker grinding the challenge form trips AccountLocked after the configured number of failures (TWO_FACTOR_MAX_ATTEMPTS, default 5). A single bad submission counts as one failed attempt even though complete_challenge tries both the TOTP and recovery-code forms. Signing in with the password again does not clear the count.

Lockout gate. complete_challenge reserves its attempt up front and returns 429 Too Many Requests if the account is already locked - even when the submitted code is correct. Without this in-method gate an attacker who tripped the lockout could still get in by submitting the right code on the next request. Every other code-checking method applies the same gate. LoginThrottleMiddleware guards the password path with the separate password lockout; composing it in front of the challenge POST route is fine.

Failure event. complete_challenge dispatches TwoFactorChallengeFailed { user_id } on a bad code (or a locked account), distinct from the password path's auth::Failed. Listeners watching for "user tried 2FA and failed" subscribe to the new event; listeners watching for "password didn't authenticate" stay on auth::Failed. The two surfaces are kept separate so a 2FA mistype does not look like a password failure to audit pipelines.

Why Suprnova diverges

The framework TOTP user_id is a String. A fixed i64, UUID, or Magnetar identifier type would tie the reusable facade to one application schema. The string boundary lets an app choose any stable identifier at the cost of one conversion at the call site.

Magnetar's integrated factor gate is separate from this retained facade. The separation preserves compatibility for applications using two_factor_credentials, but applications should not enroll the same account through both stores.

Password rehash on sign-in

A provider's validate_credentials answers whether the password matches and writes nothing, as Laravel's validateCredentials does. Auth::validate and a guard's validate therefore leave the stored hash as it is.

The rewrite of a stored hash runs in the sign-in instead, through UserProvider::rehash_password_if_required(user, credentials): after the password validates and before the user is signed in, in the session guard's attempt and once, and in Auth::logout_other_devices after its password check. The default does nothing. EloquentUserProvider and DatabaseUserProvider rewrite a $2b$ or Argon2id hash as the $2y$ hash Laravel accepts while the shared-database setting is on (see Running on a Laravel Database). A rewrite that cannot be minted or stored fails the sign-in with that error and leaves the stored hash as it was; the next sign-in tries again.

A custom provider upgrades hashes on sign-in by implementing the method:

use std::sync::Arc;
use suprnova::serde_json::Value;
use suprnova::{Authenticatable, FrameworkError, UserProvider, async_trait, hashing};

pub struct LegacyUsers;

#[async_trait]
impl UserProvider for LegacyUsers {
    async fn retrieve_by_id(
        &self,
        id: &str,
    ) -> Result<Option<Arc<dyn Authenticatable>>, FrameworkError> {
        legacy::find(id).await
    }

    async fn rehash_password_if_required(
        &self,
        user: &dyn Authenticatable,
        credentials: &Value,
    ) -> Result<(), FrameworkError> {
        let password = credentials.get("password").and_then(|v| v.as_str());
        if let (Some(plaintext), Some(stored)) = (password, user.get_auth_password())
            && hashing::needs_rehash(stored)
        {
            let fresh = hashing::hash_async(plaintext).await?;
            legacy::store_hash(&user.get_auth_identifier(), &fresh).await?;
        }
        Ok(())
    }
}

Why Suprnova diverges

Laravel rehashes on sign-in whenever its hasher reports that a hash needs it, unless hashing.rehash_on_login is off. The built-in Suprnova providers rewrite only while the shared-database setting is on, because their rewrite exists to keep a hash a Laravel application on the same database can verify. Outside that setting they leave the hash as it is; implement rehash_password_if_required on your provider to upgrade hashes on sign-in.

Remember-me

suprnova::auth_flows::remember_me re-exports the legacy suprnova::auth::remember module for compatibility.

When Magnetar is installed, ordinary Auth::attempt(..., true), Auth::issue_remember_cookie, and SessionMiddleware hydration use Magnetar's purpose-bound remember credentials. Magnetar stores verifier digests, checks the auth epoch, rotates credentials on successful use, revokes them with the user session, and reports replay or malformed-credential anomalies without exposing the secret.

The browser-facing cookie remains owned by the framework. It is encrypted with the logical remember_me name, follows SESSION_COOKIE_PREFIX, and is cleared before backend revocation so a storage failure cannot leave the browser sending the old credential.

The legacy database-row implementation remains available when no Magnetar engine is installed. New applications should initialize Magnetar and treat the legacy re-export as a transition surface.

Events

Nine events fire across the flows, one per security-state transition:

Event Fired by Carries
EmailVerified EmailVerification::verify on success, for an account that was not verified yet user_id: String
PasswordResetLinkSent PasswordReset::send_link on success - anti-enumeration silent for absent emails user_id: String, email: String
PasswordResetCompleted PasswordReset::complete on success user_id: String
AccountLocked BruteForce::record_failed_attempt on the unlocked → locked transition email: String, failed_attempts: u32
AccountUnlocked BruteForce::unlock_account when an actual unlock occurred email: String
TwoFactorEnrolled TwoFactor::confirm on success user_id: String
TwoFactorChallenged TwoFactor::complete_challenge promoted pending → authed user_id: String
TwoFactorChallengeFailed TwoFactor::complete_challenge rejected a bad code or refused a locked account user_id: String
TwoFactorDisabled TwoFactor::disable when a row was actually removed user_id: String

Every event is Debug + Clone + 'static, carries no sensitive data (no plaintext tokens, no IPs), and uses stringy identifiers so listeners can serialize them across task boundaries without leaking type information from the user-storage backend.

Listening

Subscribe via the standard event API - same surface as every other in-process event:

use std::sync::Arc;
use suprnova::async_trait;
use suprnova::auth_flows::events::AccountLocked;
use suprnova::{EventFacade, FrameworkError, Listener};

pub struct PageOpsOnLockout;

#[async_trait]
impl Listener<AccountLocked> for PageOpsOnLockout {
    async fn handle(&self, event: &AccountLocked) -> Result<(), FrameworkError> {
        tracing::warn!(
            email = %event.email,
            failed_attempts = event.failed_attempts,
            "account locked - paging ops",
        );
        // ... Slack notification, audit table append, etc.
        Ok(())
    }
}

// In bootstrap.rs:
EventFacade::listen::<AccountLocked, _>(Arc::new(PageOpsOnLockout)).await;

Listeners run on Tokio's runtime and are dispatched in registration order. See the Events chapter for the full surface.

Testing

Three fakes cover the auth-flows surface, and they compose.

Mail::fake()

Installs a process-local capture transport. Every send during the guard's lifetime lands in an in-memory buffer instead of going out:

use suprnova::mail::Mail;

#[tokio::test]
async fn send_link_dispatches_email() {
    let fake = Mail::fake();
    // ... drive the flow ...
    EmailVerification::send_link(&user, "https://app.example.com/verify")
        .await
        .unwrap();
    fake.assert_sent(|m| {
        m.to.iter().any(|a| a.email == "alice@example.com")
            && m.subject.contains("Verify")
    });
    fake.assert_sent_count(1);
}

MailFake exposes assert_sent, assert_not_sent, assert_sent_count, plus the raw captured() and count() accessors. When the guard drops, the previously-bound transport is restored - tests that interleave fakes with explicit transport binding do not leak state.

Notify::fake()

The verification link goes out as a VerifyEmailNotification. Under Notify::fake() it is recorded instead of sent, and the typed record gives you the link:

use suprnova::auth_flows::{EmailVerification, VerifyEmailNotification};
use suprnova::notifications::Notify;

#[tokio::test]
async fn send_link_notifies_the_users_address() {
    let notify = Notify::fake();
    // ... drive the flow ...
    EmailVerification::send_link(&user, "https://app.example.com/verify")
        .await
        .unwrap();
    let sent = notify
        .sent::<VerifyEmailNotification>("alice@example.com", |_| true)
        .unwrap();
    let link = &sent[0].mail.verification_link;
    assert!(link.starts_with("https://app.example.com/verify?token="));
}

The notification's public payload, which the untyped records and the NotificationSending listeners see, leaves the link out: the link carries a single-use token.

EventFacade::fake()

The same shape, but for events:

use suprnova::auth_flows::events::EmailVerified;
use suprnova::events::testing::assert_dispatched;
use suprnova::EventFacade;

#[tokio::test]
async fn verify_fires_email_verified_event() {
    let _guard = EventFacade::fake();
    // ... drive the flow ...
    EmailVerification::verify(&token).await.unwrap();
    assert_dispatched::<EmailVerified>(|e| !e.user_id.is_empty());
}

The fake records dispatched events without invoking listeners, so a listener that talks to an external service will not fire during the test. The companion assert_not_dispatched::<E>(pred) asserts the negative; dispatched_count::<E>(pred) returns the raw count for finer-grained assertions.

Integration tests for email verification and password reset

Email-verification tests create auth_flow_tokens, register a UserProvider, establish the authenticated token owner, set MAIL_FROM, and drive the facade under Mail::fake().

Password-reset tests install a MagnetarPasswordAuthEngine test adapter and assert issue, non-consuming check, atomic completion, session revocation, and single-use behavior.

Canonical source examples are:

  • framework/tests/auth_flows/email_verify.rs for actor-bound verification and single-use tokens.
  • framework/tests/auth_flows/password_reset.rs for Magnetar delegation and completion outcomes.
  • framework/tests/auth_flows/laravel_auth_gaps.rs for the verification and reset hooks, the reset timebox, the verified gate's per-request answer, and the rehash on sign-in.
  • framework/tests/magnetar_integration/default_engine.rs for real default-engine setup.
  • framework/tests/auth_flows/brute_force.rs for lockout lifecycle.
  • framework/tests/auth_flows/two_factor_challenge_flow.rs for the retained framework TOTP challenge flow.
  • framework/tests/magnetar_integration/remember_middleware.rs for remember rotation and dual-session binding.

Process-global Magnetar installation is intentionally one-shot. Put tests that need different engines in separate integration-test binaries, or install one test adapter once for the whole binary.

Reference

Symbol Purpose
suprnova::auth_flows::EmailVerification send_link, resend, check, and actor-bound verify; verify returns the user ID.
suprnova::auth_flows::EnsureEmailVerifiedMiddleware new() for 403 JSON to every caller; redirect_to(path) and redirect_to_route(name) for browser or Inertia redirects, with 403 JSON for a request that expects JSON.
suprnova::auth_flows::VerifyEmailNotification The framework's verification notification, rendered from EmailVerificationMail.
suprnova::auth_flows::PasswordReset Magnetar-first reset with a verified-account UserProvider fallback over framework auth_flow_tokens.
suprnova::auth_flows::RevokeUserCredentials Queued job that revokes a user's sessions and remember-me tokens, retried until both stores confirm; a reset queues it when a revocation fails.
suprnova::auth_flows::RevocationRetry Whether a reset queued that job: NotNeeded or Queued. A reset whose push failed returns an error instead.
suprnova::MustVerifyEmail Application-user contract for the framework verification facade; send_email_verification_notification sends the link.
suprnova::CanResetPassword Application-user contract for the provider reset path; send_password_reset_notification sends the link.
suprnova::auth_flows::token_store::create_auth_flow_tokens_table SeaORM table definition for framework verification tokens.
suprnova::auth_flows::BruteForce Magnetar-backed account lockout facade.
suprnova::auth_flows::LoginThrottleMiddleware HTTP middleware that returns 429 before the login handler when the account is locked.
suprnova::auth_flows::TwoFactor Retained framework TOTP enrollment, verification, recovery, and challenge facade.
suprnova::auth_flows::TwoFactorUser Application-user bridge for the framework TOTP facade.
suprnova::auth_flows::TwoFactorChallengeMiddleware Gate for sessions waiting on the framework TOTP challenge.
suprnova::auth_flows::remember_me Compatibility re-export of the legacy framework remember module.
suprnova::MagnetarConfig / suprnova::init_magnetar Default Magnetar engine configuration and one-shot installation.
suprnova::auth_flows::events::* Authentication lifecycle events.

Next

  • Authentication - guards, providers, the Auth facade, AuthMiddleware.
  • Mail - the transport layer the send_link calls dispatch through.
  • Notifications - the channel the verification notification goes through.
  • Events - registering listeners for the nine auth-flow events.
  • Rate Limiting - pair RateLimitMiddleware::ip_based with LoginThrottleMiddleware for layered defence.
  • Session - what start_challenge / complete_challenge touch when they rotate the session id.