AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/cli/src/auth.rs

Latest change 30ab30f1996c3474188ec3dd71ba00efc5e3eb8b - feat(auth): email-OTP login flow + user roles by Ólafur Búi Ólafsson

//! Cookie-based session authentication on the embedded B+tree.
//!
//! This is the framework's reference auth feature: a durable session store, a
//! tiny users table, a plain-HTML login form, a session-cookie middleware
//! helper, a protected page, and logout. It mirrors the guestbook form-actions
//! demo (validate → store → 303 Post/Redirect/Get) and the record store
//! (`Arc<Mutex<BTree>>`, durable on commit), adding only what auth needs: a
//! `Cookie:` request-header reader and a `Set-Cookie:` response-header writer.
//!
//! ## Security caveats (read these — this is demo-grade, not production)
//!
//! * **PRNG:** session ids are derived from `std` only — `SystemTime` nanos
//!   mixed with a per-process atomic counter and the pid, run through a
//!   SplitMix64 diffuser. This is a framework-demo CSPRNG-lite. It is *not* a
//!   cryptographically secure RNG; do not rely on it to resist a determined
//!   attacker guessing session ids.
//! * **Password hash:** a salted, stretched SHA-1 (reusing the WebSocket SHA-1).
//!   It is salted and iterated, but SHA-1 is fast and not memory-hard — this is
//!   **not bcrypt/argon2** and must not be presented as such.
//! * **Transport:** the cookie is `HttpOnly`, `Path=/`, `SameSite=Lax`. It is
//!   not marked `Secure` here because TLS terminates at the edge (nginx); the
//!   HTTPS layer there is what actually protects the cookie in transit.
//!
//! ## Email-OTP login (additive — password login is unchanged)
//!
//! Alongside the password form, an app can offer passwordless email-OTP login.
//! A request mints a 6-digit numeric code, stores a *salted hash* of it (never
//! the code itself) in `data/otp.db` keyed by the normalized email, with a
//! 10-minute TTL and a 5-attempt cap. Verification checks the code, expiry, and
//! attempt cap; on success it creates an ordinary session (reusing the password
//! login's session mechanism) and clears the OTP.
//!
//! Sending the code is a **pluggable seam**: [`OtpDeliver`] is *given* the
//! `(email, code)` pair and owns delivery. The framework ships no outbound-email
//! client (it has no TLS stack), so the default [`LogDeliver`] logs and records
//! the code — fully testable offline. A real sender (e.g. a local SMTP sidecar)
//! implements [`OtpDeliver`] and is injected via [`State::open_with_deliver`].
//!
//! ## Roles
//!
//! Each user record carries a [`Role`] (`user` or `admin`). [`State::current_user`]
//! exposes it on [`CurrentUser`]; [`State::current_role`] and
//! [`State::require_role`] gate admin-only routes (admin satisfies any required
//! role). Records written before roles existed (no `role` field) read back as
//! [`Role::User`], so old stores stay valid and the existing demo/password user
//! keeps working unchanged.

use crate::cmd_serve::{build_engine, error_page, load_context};
use akurai_json::Value;
use akurai_storage::BTree;
use akurai_ws::sha1::sha1;
use std::io;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

use akurai_http::{form, Method, Reply, Request, Response};

/// The seeded demo user so the login flow can be exercised out of the box.
pub const DEMO_USERNAME: &str = "demo";
/// The seeded demo user's password. Demo-grade; documented, not a secret.
pub const DEMO_PASSWORD: &str = "akurai";

/// The session cookie name.
const COOKIE_NAME: &str = "session";

/// How long a minted OTP stays valid, in seconds (10 minutes).
const OTP_TTL_SECS: i64 = 600;
/// How many wrong guesses an OTP tolerates before it is locked out.
const OTP_MAX_ATTEMPTS: i64 = 5;
/// The number of decimal digits in a generated OTP code.
const OTP_CODE_DIGITS: usize = 6;

// ---------------------------------------------------------------------------
// Roles
// ---------------------------------------------------------------------------

/// A user's authorization role. `User` is the default; `Admin` gates privileged
/// routes. The hierarchy is flat with one rule: an admin satisfies any required
/// role (see [`State::require_role`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    /// An ordinary authenticated user.
    User,
    /// An administrator. Satisfies every role check.
    Admin,
}

impl Role {
    /// The wire/storage string for this role (`"user"` / `"admin"`).
    pub fn as_str(self) -> &'static str {
        match self {
            Role::User => "user",
            Role::Admin => "admin",
        }
    }

    /// Parse a stored role string. Anything other than `"admin"` (including a
    /// missing field) is the safe default, [`Role::User`].
    pub fn parse(s: &str) -> Role {
        match s {
            "admin" => Role::Admin,
            _ => Role::User,
        }
    }
}

/// The signed-in user behind a request: their name (username or email) and role.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurrentUser {
    /// The account identifier — a username for password users, an email for
    /// OTP accounts.
    pub username: String,
    /// The account's authorization role.
    pub role: Role,
}

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------

/// Auth handler state: durable session, user, and OTP stores, plus the pluggable
/// OTP delivery seam. Every store is an `Arc`, and the deliver is an `Arc<dyn>`,
/// so the whole struct clones freely into the server's request closure.
#[derive(Clone)]
pub struct State {
    sessions: SessionStore,
    users: UserStore,
    otp: OtpStore,
    deliver: Arc<dyn OtpDeliver>,
}

impl State {
    /// Open (or create) the auth stores under `data_dir` with the default
    /// [`LogDeliver`] (logs/records the OTP — no real email is sent). Seeds the
    /// demo user (as an admin, so admin gating is demoable) if absent.
    pub fn open(data_dir: &Path) -> io::Result<State> {
        State::open_with_deliver(data_dir, Arc::new(LogDeliver::new()))
    }

    /// Like [`State::open`], but with an app-supplied OTP delivery backend. This
    /// is the seam a real sender (e.g. a local SMTP sidecar) plugs into:
    /// implement [`OtpDeliver`] and pass it here.
    pub fn open_with_deliver(data_dir: &Path, deliver: Arc<dyn OtpDeliver>) -> io::Result<State> {
        let sessions = SessionStore::open(data_dir.join("sessions.db"))?;
        let users = UserStore::open(data_dir.join("users.db"))?;
        let otp = OtpStore::open(data_dir.join("otp.db"))?;
        // The demo/password user is seeded as an admin so the role-gated parts
        // of the demo work out of the box. Idempotent: an existing record (incl.
        // a pre-roles one) is left untouched.
        users.ensure_user(DEMO_USERNAME, DEMO_PASSWORD, Role::Admin)?;
        Ok(State {
            sessions,
            users,
            otp,
            deliver,
        })
    }

    /// The [`CurrentUser`] named by a request's session cookie, if it maps to a
    /// live session. This is the session middleware/helper: read `Cookie`, look
    /// up the session, resolve the account's role, yield the user (or `None`).
    pub fn current_user(&self, req: &Request) -> Option<CurrentUser> {
        let cookie = req.header("Cookie")?;
        let id = parse_cookie(cookie, COOKIE_NAME)?;
        let session = self.sessions.lookup(&id).ok().flatten()?;
        let role = self.users.role_of(&session.username).unwrap_or(Role::User);
        Some(CurrentUser {
            username: session.username,
            role,
        })
    }

    /// The role of the request's signed-in user, or `None` if unauthenticated.
    pub fn current_role(&self, req: &Request) -> Option<Role> {
        self.current_user(req).map(|u| u.role)
    }

    /// Whether the request's user satisfies `role`. An admin satisfies any
    /// required role; an unauthenticated request never does.
    pub fn require_role(&self, req: &Request, role: Role) -> bool {
        match self.current_role(req) {
            Some(Role::Admin) => true,
            Some(actual) => actual == role,
            None => false,
        }
    }

    /// Mint and deliver a fresh OTP for `email` through the configured backend.
    pub fn request_otp(&self, email: &str) -> io::Result<()> {
        self.otp.request(email, self.deliver.as_ref())
    }

    /// Verify a submitted OTP `code` for `email`. On [`OtpOutcome::Verified`] the
    /// stored OTP is cleared; the caller is responsible for creating the session.
    pub fn verify_otp(&self, email: &str, code: &str) -> io::Result<OtpOutcome> {
        self.otp.verify(email, code)
    }

    /// Ensure a passwordless account exists for `email` (created on first OTP
    /// login). Such a record carries only a role — no password hash — so it can
    /// never be used for password login. Idempotent.
    pub fn ensure_account(&self, email: &str) -> io::Result<()> {
        self.users
            .ensure_account(&normalize_email(email), Role::User)
    }
}

// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------

/// Route `GET`/`POST /login`.
pub fn login(state: &State, root: &Path, req: &Request) -> Reply {
    // Already signed in? Skip the form and go to the protected page.
    if state.current_user(req).is_some() {
        return Reply::Response(redirect("/account"));
    }
    let response = match req.method {
        Method::Post => login_submit(state, root, req),
        _ => render_login(root, "", ""),
    };
    Reply::Response(response)
}

/// Validate credentials; on success create a session, set the cookie, and
/// 303-redirect to the protected page. On failure re-render with an error.
fn login_submit(state: &State, root: &Path, req: &Request) -> Response {
    let pairs = form::parse_urlencoded(&req.body_str());
    let username = form::field(&pairs, "username")
        .unwrap_or("")
        .trim()
        .to_string();
    let password = form::field(&pairs, "password").unwrap_or("").to_string();

    let ok = state.users.verify(&username, &password).unwrap_or(false);
    if !ok {
        return render_login(root, "Invalid username or password.", &username);
    }

    let id = match state.sessions.create(&username) {
        Ok(id) => id,
        Err(e) => {
            return Response::new(500)
                .with_html(&error_page(&format!("could not start session: {e}")))
        }
    };
    redirect("/account").with_header("Set-Cookie", &set_cookie(&id))
}

/// Route `GET`/`POST /login/otp`: the passwordless email-OTP flow. A `GET` (or a
/// `POST` with no `code`) is the *request* step — it mints and delivers a code;
/// a `POST` carrying a `code` is the *verify* step — on success it creates a
/// session and redirects to the protected page.
pub fn otp(state: &State, root: &Path, req: &Request) -> Reply {
    if state.current_user(req).is_some() {
        return Reply::Response(redirect("/account"));
    }
    let response = match req.method {
        Method::Post => otp_submit(state, root, req),
        _ => render_otp(root, "", "", false),
    };
    Reply::Response(response)
}

/// Drive the two-step OTP form. Empty `code` ⇒ request a code; non-empty `code`
/// ⇒ verify it and, on success, sign the user in.
fn otp_submit(state: &State, root: &Path, req: &Request) -> Response {
    let pairs = form::parse_urlencoded(&req.body_str());
    let email = normalize_email(form::field(&pairs, "email").unwrap_or(""));
    let code = form::field(&pairs, "code").unwrap_or("").trim().to_string();

    if email.is_empty() {
        return render_otp(root, "Enter your email address.", "", false);
    }

    // Request step: no code submitted yet — mint and deliver one.
    if code.is_empty() {
        if let Err(e) = state.request_otp(&email) {
            return Response::new(500).with_html(&error_page(&format!("could not send code: {e}")));
        }
        return render_otp(root, "", &email, true);
    }

    // Verify step.
    match state.verify_otp(&email, &code) {
        Ok(OtpOutcome::Verified) => {
            if let Err(e) = state.ensure_account(&email) {
                return Response::new(500)
                    .with_html(&error_page(&format!("could not create account: {e}")));
            }
            match state.sessions.create(&email) {
                Ok(id) => redirect("/account").with_header("Set-Cookie", &set_cookie(&id)),
                Err(e) => Response::new(500)
                    .with_html(&error_page(&format!("could not start session: {e}"))),
            }
        }
        Ok(OtpOutcome::Expired) => {
            render_otp(root, "That code expired. Request a new one.", &email, false)
        }
        Ok(OtpOutcome::TooManyAttempts) => render_otp(
            root,
            "Too many attempts. Request a new code.",
            &email,
            false,
        ),
        Ok(OtpOutcome::NoCode) => render_otp(
            root,
            "No code outstanding. Request one first.",
            &email,
            false,
        ),
        Ok(OtpOutcome::WrongCode) => render_otp(root, "Incorrect code. Try again.", &email, true),
        Err(e) => Response::new(500).with_html(&error_page(&format!("verify failed: {e}"))),
    }
}

/// Render the protected page, or redirect to `/login` when unauthenticated.
pub fn account(state: &State, root: &Path, req: &Request) -> Reply {
    let Some(user) = state.current_user(req) else {
        return Reply::Response(redirect("/login"));
    };
    let is_admin = state.require_role(req, Role::Admin);
    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Reply::Response(Response::new(500).with_html(&error_page(&msg))),
    };
    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        pairs.push(("username".into(), Value::Str(user.username)));
        pairs.push(("role".into(), Value::Str(user.role.as_str().to_string())));
        pairs.push(("is_admin".into(), bool_flag(is_admin)));
    }
    let response = match engine.render("account", &context) {
        Ok(html) => Response::ok().with_html(&html),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    };
    Reply::Response(response)
}

/// Clear the session and expire the cookie, then redirect to `/login`.
pub fn logout(state: &State, req: &Request) -> Reply {
    if let Some(cookie) = req.header("Cookie") {
        if let Some(id) = parse_cookie(cookie, COOKIE_NAME) {
            let _ = state.sessions.delete(&id);
        }
    }
    Reply::Response(redirect("/login").with_header("Set-Cookie", &expired_cookie()))
}

/// Render the login template with an optional error and the username the user
/// already typed (so a failed attempt does not clear the field).
fn render_login(root: &Path, error: &str, username: &str) -> Response {
    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
    };
    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        pairs.push(("error".into(), Value::Str(error.to_string())));
        pairs.push(("form_username".into(), Value::Str(username.to_string())));
    }
    match engine.render("login", &context) {
        Ok(html) => Response::ok().with_html(&html),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    }
}

/// Render the OTP template. `sent` flips the form from "enter your email" to
/// "enter the code we sent"; `error` shows an alert; `email` keeps the field
/// populated across steps.
fn render_otp(root: &Path, error: &str, email: &str, sent: bool) -> Response {
    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
    };
    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        pairs.push(("error".into(), Value::Str(error.to_string())));
        pairs.push(("form_email".into(), Value::Str(email.to_string())));
        pairs.push(("sent".into(), bool_flag(sent)));
    }
    match engine.render("otp", &context) {
        Ok(html) => Response::ok().with_html(&html),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    }
}

/// A 303 See Other redirect (Post/Redirect/Get) to `location`.
fn redirect(location: &str) -> Response {
    Response::new(303).with_header("Location", location)
}

/// A template-truthy flag: a non-empty string when `true`, empty when `false`.
/// The template engine treats the empty string as falsy (the same convention the
/// login template uses for `{% if error %}`), so `{% if sent %}` works.
fn bool_flag(on: bool) -> Value {
    Value::Str(if on { "1" } else { "" }.to_string())
}

/// Normalize an email for use as a stable storage key: trimmed and lowercased.
fn normalize_email(email: &str) -> String {
    email.trim().to_ascii_lowercase()
}

// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------

/// Read a single cookie value out of a `Cookie:` request header. The header is
/// a `; `-separated list of `name=value` pairs; returns the first match for
/// `name`, or `None`.
pub fn parse_cookie(header: &str, name: &str) -> Option<String> {
    header.split(';').find_map(|pair| {
        let (k, v) = pair.split_once('=')?;
        if k.trim() == name {
            Some(v.trim().to_string())
        } else {
            None
        }
    })
}

/// The `Set-Cookie` value that installs a session: `HttpOnly` (no JS access),
/// `Path=/` (whole site), `SameSite=Lax` (sent on top-level navigations only).
/// Not `Secure` — TLS terminates at the edge (see the module caveats).
pub fn set_cookie(id: &str) -> String {
    format!("{COOKIE_NAME}={id}; HttpOnly; Path=/; SameSite=Lax")
}

/// The `Set-Cookie` value that expires the session cookie (empty value,
/// `Max-Age=0`), telling the browser to drop it immediately.
pub fn expired_cookie() -> String {
    format!("{COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0")
}

// ---------------------------------------------------------------------------
// Session store
// ---------------------------------------------------------------------------

/// A live session: who it belongs to and when it started (unix seconds).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
    pub username: String,
    pub created: i64,
}

/// A durable, clonable session store keyed by opaque session id.
#[derive(Clone)]
struct SessionStore {
    tree: Arc<Mutex<BTree>>,
}

impl SessionStore {
    fn open(path: impl AsRef<Path>) -> io::Result<SessionStore> {
        Ok(SessionStore {
            tree: Arc::new(Mutex::new(BTree::open(path)?)),
        })
    }

    /// Create a session for `username`, returning its new opaque id. Durable on
    /// return (it commits).
    fn create(&self, username: &str) -> io::Result<String> {
        let id = random_id();
        let created = now_secs();
        let record = Value::Object(vec![
            ("username".into(), Value::Str(username.to_string())),
            ("created".into(), Value::Int(created)),
        ]);
        let mut tree = self.tree.lock().unwrap();
        tree.insert(id.as_bytes(), record.to_json().as_bytes())?;
        tree.commit()?;
        Ok(id)
    }

    /// Look up a session by id, or `None` if there is no such session.
    fn lookup(&self, id: &str) -> io::Result<Option<Session>> {
        let mut tree = self.tree.lock().unwrap();
        let Some(bytes) = tree.get(id.as_bytes())? else {
            return Ok(None);
        };
        Ok(decode_session(&bytes))
    }

    /// Delete a session by id. Durable on return. Returns whether it existed.
    fn delete(&self, id: &str) -> io::Result<bool> {
        let mut tree = self.tree.lock().unwrap();
        let removed = tree.delete(id.as_bytes())?;
        tree.commit()?;
        Ok(removed)
    }
}

/// Decode a stored session JSON blob into a [`Session`], or `None` if malformed.
fn decode_session(bytes: &[u8]) -> Option<Session> {
    let text = std::str::from_utf8(bytes).ok()?;
    let value = akurai_json::parse(text).ok()?;
    let username = value.get("username")?.as_str()?.to_string();
    let created = match value.get("created") {
        Some(Value::Int(n)) => *n,
        _ => 0,
    };
    Some(Session { username, created })
}

// ---------------------------------------------------------------------------
// User store
// ---------------------------------------------------------------------------

/// A durable, clonable users table keyed by username (or email for passwordless
/// accounts). A password user stores a salt + salted hash + role; a passwordless
/// OTP account stores only a role (no hash ⇒ password login can never succeed).
#[derive(Clone)]
struct UserStore {
    tree: Arc<Mutex<BTree>>,
}

impl UserStore {
    fn open(path: impl AsRef<Path>) -> io::Result<UserStore> {
        Ok(UserStore {
            tree: Arc::new(Mutex::new(BTree::open(path)?)),
        })
    }

    /// Create `username` with `password` and `role` if it does not already exist.
    /// Idempotent — an existing user is left untouched (so the demo password is
    /// not reset, and a pre-roles record is not clobbered, on every boot).
    fn ensure_user(&self, username: &str, password: &str, role: Role) -> io::Result<()> {
        let mut tree = self.tree.lock().unwrap();
        if tree.get(username.as_bytes())?.is_some() {
            return Ok(());
        }
        let salt = random_id();
        let record = Value::Object(vec![
            ("salt".into(), Value::Str(salt.clone())),
            ("hash".into(), Value::Str(hash_password(&salt, password))),
            ("role".into(), Value::Str(role.as_str().to_string())),
        ]);
        tree.insert(username.as_bytes(), record.to_json().as_bytes())?;
        tree.commit()?;
        Ok(())
    }

    /// Create a passwordless account (a role-only record) for `email` if absent.
    /// Idempotent. Used when an OTP login succeeds for a new email.
    fn ensure_account(&self, email: &str, role: Role) -> io::Result<()> {
        let mut tree = self.tree.lock().unwrap();
        if tree.get(email.as_bytes())?.is_some() {
            return Ok(());
        }
        let record = Value::Object(vec![("role".into(), Value::Str(role.as_str().to_string()))]);
        tree.insert(email.as_bytes(), record.to_json().as_bytes())?;
        tree.commit()?;
        Ok(())
    }

    /// Verify a `username`/`password` pair in constant-ish time against the
    /// stored salted hash. A missing user, or a passwordless account (no hash),
    /// returns `Ok(false)`.
    fn verify(&self, username: &str, password: &str) -> io::Result<bool> {
        let mut tree = self.tree.lock().unwrap();
        let Some(bytes) = tree.get(username.as_bytes())? else {
            return Ok(false);
        };
        let Some((salt, hash)) = decode_user(&bytes) else {
            return Ok(false);
        };
        Ok(constant_time_eq(&hash_password(&salt, password), &hash))
    }

    /// The role of `username`, or `None` if there is no such record. A record
    /// with no `role` field (written before roles existed) reads as
    /// [`Role::User`].
    fn role_of(&self, username: &str) -> Option<Role> {
        let mut tree = self.tree.lock().unwrap();
        let bytes = tree.get(username.as_bytes()).ok()??;
        let text = std::str::from_utf8(&bytes).ok()?;
        let value = akurai_json::parse(text).ok()?;
        let role = value
            .get("role")
            .and_then(Value::as_str)
            .map(Role::parse)
            .unwrap_or(Role::User);
        Some(role)
    }
}

/// Decode a stored user JSON blob into `(salt, hash)`, or `None` if absent or
/// malformed (e.g. a passwordless account that has no hash).
fn decode_user(bytes: &[u8]) -> Option<(String, String)> {
    let text = std::str::from_utf8(bytes).ok()?;
    let value = akurai_json::parse(text).ok()?;
    let salt = value.get("salt")?.as_str()?.to_string();
    let hash = value.get("hash")?.as_str()?.to_string();
    Some((salt, hash))
}

// ---------------------------------------------------------------------------
// OTP store (email one-time codes)
// ---------------------------------------------------------------------------

/// The result of verifying a submitted OTP code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OtpOutcome {
    /// The code matched, was unexpired, and within the attempt cap. The stored
    /// OTP has been cleared.
    Verified,
    /// The code did not match; an attempt was consumed.
    WrongCode,
    /// The stored code's TTL had elapsed; it has been cleared.
    Expired,
    /// The attempt cap was hit; the code is locked out until re-requested.
    TooManyAttempts,
    /// There was no outstanding code for this email.
    NoCode,
}

/// The pluggable OTP delivery seam. An implementation is *given* the email and
/// the freshly minted code and is responsible for getting it to the user
/// (email, SMS, a sidecar, …). The framework intentionally ships no outbound
/// transport — wire a real one via [`State::open_with_deliver`].
pub trait OtpDeliver: Send + Sync {
    /// Deliver `code` to `email`. Returning `Err` fails the request step.
    fn deliver(&self, email: &str, code: &str) -> io::Result<()>;
}

/// The default [`OtpDeliver`]: it logs the code to stdout and records every
/// delivery in memory so the flow is fully testable offline. **Never** use this
/// in production — the code is not actually sent anywhere private.
#[derive(Clone, Default)]
pub struct LogDeliver {
    delivered: Arc<Mutex<Vec<(String, String)>>>,
}

impl LogDeliver {
    /// A fresh logging deliverer with an empty record.
    pub fn new() -> LogDeliver {
        LogDeliver {
            delivered: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// The most recent code delivered to `email`, if any. Handy for tests and
    /// local development; not used by the framework itself.
    #[allow(dead_code)]
    pub fn last_code(&self, email: &str) -> Option<String> {
        let email = normalize_email(email);
        let log = self.delivered.lock().unwrap();
        log.iter()
            .rev()
            .find(|(e, _)| *e == email)
            .map(|(_, c)| c.clone())
    }
}

impl OtpDeliver for LogDeliver {
    fn deliver(&self, email: &str, code: &str) -> io::Result<()> {
        println!("  [otp] code for {email}: {code} (LogDeliver — wire a real sender via State::open_with_deliver)");
        self.delivered
            .lock()
            .unwrap()
            .push((email.to_string(), code.to_string()));
        Ok(())
    }
}

/// A durable, clonable store of outstanding OTP codes keyed by normalized email.
#[derive(Clone)]
struct OtpStore {
    tree: Arc<Mutex<BTree>>,
}

/// A decoded OTP record.
struct OtpRecord {
    salt: String,
    code_hash: String,
    expires_at: i64,
    attempts: i64,
}

impl OtpStore {
    fn open(path: impl AsRef<Path>) -> io::Result<OtpStore> {
        Ok(OtpStore {
            tree: Arc::new(Mutex::new(BTree::open(path)?)),
        })
    }

    /// Mint a fresh code for `email`, store its *salted hash* (never the code)
    /// with a TTL and a zeroed attempt count (overwriting any prior code), then
    /// hand the plaintext code to `deliver`. Durable before delivery.
    fn request(&self, email: &str, deliver: &dyn OtpDeliver) -> io::Result<()> {
        let email = normalize_email(email);
        let code = random_code();
        let salt = random_id();
        let record = Value::Object(vec![
            ("salt".into(), Value::Str(salt.clone())),
            ("code_hash".into(), Value::Str(hash_password(&salt, &code))),
            ("expires_at".into(), Value::Int(now_secs() + OTP_TTL_SECS)),
            ("attempts".into(), Value::Int(0)),
        ]);
        {
            let mut tree = self.tree.lock().unwrap();
            tree.insert(email.as_bytes(), record.to_json().as_bytes())?;
            tree.commit()?;
        }
        deliver.deliver(&email, &code)
    }

    /// Verify `code` for `email`: check existence, expiry, the attempt cap, and
    /// the salted-hash match. On success the OTP is cleared; on a wrong guess an
    /// attempt is consumed and persisted.
    fn verify(&self, email: &str, code: &str) -> io::Result<OtpOutcome> {
        let email = normalize_email(email);
        let code = code.trim();
        let mut tree = self.tree.lock().unwrap();

        let Some(bytes) = tree.get(email.as_bytes())? else {
            return Ok(OtpOutcome::NoCode);
        };
        let Some(rec) = decode_otp(&bytes) else {
            // Malformed record: clear it and treat as no outstanding code.
            tree.delete(email.as_bytes())?;
            tree.commit()?;
            return Ok(OtpOutcome::NoCode);
        };

        if now_secs() > rec.expires_at {
            tree.delete(email.as_bytes())?;
            tree.commit()?;
            return Ok(OtpOutcome::Expired);
        }
        if rec.attempts >= OTP_MAX_ATTEMPTS {
            return Ok(OtpOutcome::TooManyAttempts);
        }

        if constant_time_eq(&hash_password(&rec.salt, code), &rec.code_hash) {
            tree.delete(email.as_bytes())?;
            tree.commit()?;
            return Ok(OtpOutcome::Verified);
        }

        // Wrong code: burn an attempt and persist.
        let attempts = rec.attempts + 1;
        let updated = Value::Object(vec![
            ("salt".into(), Value::Str(rec.salt)),
            ("code_hash".into(), Value::Str(rec.code_hash)),
            ("expires_at".into(), Value::Int(rec.expires_at)),
            ("attempts".into(), Value::Int(attempts)),
        ]);
        tree.insert(email.as_bytes(), updated.to_json().as_bytes())?;
        tree.commit()?;

        if attempts >= OTP_MAX_ATTEMPTS {
            Ok(OtpOutcome::TooManyAttempts)
        } else {
            Ok(OtpOutcome::WrongCode)
        }
    }

    /// Test-only: seed an OTP with an explicit `expires_at` so expiry can be
    /// exercised without sleeping. Returns nothing — the caller knows `code`.
    #[cfg(test)]
    fn seed_code(&self, email: &str, code: &str, expires_at: i64) -> io::Result<()> {
        let email = normalize_email(email);
        let salt = random_id();
        let record = Value::Object(vec![
            ("salt".into(), Value::Str(salt.clone())),
            ("code_hash".into(), Value::Str(hash_password(&salt, code))),
            ("expires_at".into(), Value::Int(expires_at)),
            ("attempts".into(), Value::Int(0)),
        ]);
        let mut tree = self.tree.lock().unwrap();
        tree.insert(email.as_bytes(), record.to_json().as_bytes())?;
        tree.commit()?;
        Ok(())
    }
}

/// Decode a stored OTP JSON blob into an [`OtpRecord`], or `None` if malformed.
fn decode_otp(bytes: &[u8]) -> Option<OtpRecord> {
    let text = std::str::from_utf8(bytes).ok()?;
    let value = akurai_json::parse(text).ok()?;
    Some(OtpRecord {
        salt: value.get("salt")?.as_str()?.to_string(),
        code_hash: value.get("code_hash")?.as_str()?.to_string(),
        expires_at: value.get("expires_at").and_then(Value::as_i64).unwrap_or(0),
        attempts: value.get("attempts").and_then(Value::as_i64).unwrap_or(0),
    })
}

// ---------------------------------------------------------------------------
// Password hashing (demo-grade — see module caveats)
// ---------------------------------------------------------------------------

/// Hash stretch rounds. SHA-1 is fast, so we iterate to slow brute force a
/// little; this is *not* a memory-hard KDF and must not be sold as bcrypt.
const HASH_ROUNDS: usize = 4096;

/// A salted, stretched SHA-1 of `password`, returned as lowercase hex. Same
/// `(salt, password)` always yields the same hash; a different salt or password
/// yields a different one. Reused to hash OTP codes (with a per-code salt).
pub fn hash_password(salt: &str, password: &str) -> String {
    let mut digest = sha1(format!("{salt}:{password}").as_bytes());
    // Re-mix the salt into every round so the salt is not just a first-round
    // prefix an attacker can strip.
    let salt_bytes = salt.as_bytes();
    for _ in 0..HASH_ROUNDS {
        let mut buf = Vec::with_capacity(digest.len() + salt_bytes.len());
        buf.extend_from_slice(&digest);
        buf.extend_from_slice(salt_bytes);
        digest = sha1(&buf);
    }
    to_hex(&digest)
}

/// Compare two hex hashes without an early-exit short circuit on the first
/// differing byte (length differences still short-circuit, which is fine here:
/// our hashes are always the same length).
fn constant_time_eq(a: &str, b: &str) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.bytes().zip(b.bytes()) {
        diff |= x ^ y;
    }
    diff == 0
}

/// Lowercase hex of a byte slice.
fn to_hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

// ---------------------------------------------------------------------------
// CSPRNG-lite id generation (demo-grade — see module caveats)
// ---------------------------------------------------------------------------

/// A monotonic per-process counter mixed into every id so two ids minted within
/// the same `SystemTime` tick still differ.
static ID_COUNTER: AtomicU64 = AtomicU64::new(0);

/// A 128-bit opaque id as 32 hex chars, derived from `std` only:
/// `SystemTime` nanos + pid + an atomic counter, diffused through SplitMix64.
/// Demo-grade randomness — see the module caveats.
fn random_id() -> String {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0);
    let pid = std::process::id() as u64;
    let count = ID_COUNTER.fetch_add(1, Ordering::Relaxed);

    let mut seed_a = nanos ^ pid.rotate_left(32);
    let mut seed_b = count
        .wrapping_add(nanos.rotate_left(17))
        .wrapping_mul(0x9E37_79B9_7F4A_7C15);
    let a = split_mix64(&mut seed_a);
    let b = split_mix64(&mut seed_b);
    format!("{a:016x}{b:016x}")
}

/// A zero-padded `OTP_CODE_DIGITS`-digit numeric code, derived from the same
/// `std`-only entropy as [`random_id`] (demo-grade — see the module caveats).
fn random_code() -> String {
    let id = random_id();
    let word = u32::from_str_radix(&id[..8], 16).unwrap_or(0);
    let n = word % 10u32.pow(OTP_CODE_DIGITS as u32);
    format!("{n:0width$}", width = OTP_CODE_DIGITS)
}

/// One SplitMix64 step (Vigna): advance `state` and return a well-diffused word.
fn split_mix64(state: &mut u64) -> u64 {
    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
    let mut z = *state;
    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
    z ^ (z >> 31)
}

/// Current unix time in whole seconds (0 on a pre-epoch clock, which never
/// happens in practice).
fn now_secs() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    /// The scratch base: `CARGO_TARGET_TMPDIR` when present (integration tests),
    /// otherwise a dir under this crate's own `target/` — never `/tmp`.
    fn scratch_base() -> PathBuf {
        match std::env::var_os("CARGO_TARGET_TMPDIR") {
            Some(dir) => PathBuf::from(dir),
            None => PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("target")
                .join("auth-test-tmp"),
        }
    }

    /// A unique scratch file path under [`scratch_base`] (never `/tmp`).
    fn tmp(name: &str) -> PathBuf {
        let base = scratch_base();
        std::fs::create_dir_all(&base).unwrap();
        let p = base.join(format!(
            "akurai-auth-{name}-{}-{}.db",
            std::process::id(),
            ID_COUNTER.fetch_add(1, Ordering::Relaxed)
        ));
        let _ = std::fs::remove_file(&p);
        p
    }

    // --- session store ----------------------------------------------------

    #[test]
    fn session_create_lookup_delete_roundtrip() {
        let path = tmp("session-roundtrip");
        let store = SessionStore::open(&path).unwrap();
        let id = store.create("ada").unwrap();

        let found = store.lookup(&id).unwrap().expect("session should exist");
        assert_eq!(found.username, "ada");
        assert!(found.created > 0);

        assert!(store.delete(&id).unwrap(), "delete reports it existed");
        assert!(store.lookup(&id).unwrap().is_none(), "gone after delete");
        assert!(!store.delete(&id).unwrap(), "second delete is a no-op");
    }

    #[test]
    fn unknown_session_id_is_none() {
        let path = tmp("session-unknown");
        let store = SessionStore::open(&path).unwrap();
        assert!(store.lookup("does-not-exist").unwrap().is_none());
    }

    #[test]
    fn sessions_persist_across_reopen() {
        let path = tmp("session-persist");
        let id = {
            let store = SessionStore::open(&path).unwrap();
            store.create("grace").unwrap()
        };
        // Reopen from disk: the session survives.
        let store = SessionStore::open(&path).unwrap();
        let found = store.lookup(&id).unwrap().expect("session survives reopen");
        assert_eq!(found.username, "grace");
    }

    #[test]
    fn ids_are_unique_even_in_a_tight_loop() {
        let path = tmp("session-unique");
        let store = SessionStore::open(&path).unwrap();
        let mut ids = std::collections::HashSet::new();
        for _ in 0..1000 {
            assert!(ids.insert(store.create("u").unwrap()), "no id collisions");
        }
    }

    // --- password hashing -------------------------------------------------

    #[test]
    fn same_input_same_hash() {
        assert_eq!(hash_password("salt", "pw"), hash_password("salt", "pw"));
    }

    #[test]
    fn different_password_different_hash() {
        assert_ne!(hash_password("salt", "pw"), hash_password("salt", "other"));
    }

    #[test]
    fn different_salt_different_hash() {
        assert_ne!(hash_password("salt-a", "pw"), hash_password("salt-b", "pw"));
    }

    #[test]
    fn hash_is_hex_and_never_the_plaintext() {
        let h = hash_password("s", "hunter2");
        assert_eq!(h.len(), 40, "SHA-1 hex is 40 chars");
        assert!(h.bytes().all(|b| b.is_ascii_hexdigit()));
        assert!(!h.contains("hunter2"));
    }

    #[test]
    fn constant_time_eq_matches_plain_eq() {
        assert!(constant_time_eq("abc", "abc"));
        assert!(!constant_time_eq("abc", "abd"));
        assert!(!constant_time_eq("abc", "abcd"));
    }

    // --- user store -------------------------------------------------------

    #[test]
    fn user_verify_accepts_correct_rejects_wrong() {
        let path = tmp("user-verify");
        let users = UserStore::open(&path).unwrap();
        users.ensure_user("demo", "akurai", Role::User).unwrap();

        assert!(users.verify("demo", "akurai").unwrap(), "right password");
        assert!(!users.verify("demo", "nope").unwrap(), "wrong password");
        assert!(!users.verify("ghost", "akurai").unwrap(), "no such user");
    }

    #[test]
    fn ensure_user_is_idempotent() {
        let path = tmp("user-idempotent");
        let users = UserStore::open(&path).unwrap();
        users.ensure_user("demo", "first", Role::User).unwrap();
        // A second call with a different password must NOT overwrite the user.
        users.ensure_user("demo", "second", Role::User).unwrap();
        assert!(users.verify("demo", "first").unwrap());
        assert!(!users.verify("demo", "second").unwrap());
    }

    // --- roles ------------------------------------------------------------

    #[test]
    fn role_of_reads_stored_role_and_defaults_to_user() {
        let path = tmp("role-of");
        let users = UserStore::open(&path).unwrap();
        users.ensure_user("boss", "pw", Role::Admin).unwrap();
        users.ensure_user("worker", "pw", Role::User).unwrap();

        assert_eq!(users.role_of("boss"), Some(Role::Admin));
        assert_eq!(users.role_of("worker"), Some(Role::User));
        assert_eq!(users.role_of("ghost"), None, "no record → None");
    }

    #[test]
    fn role_string_roundtrips_and_unknown_is_user() {
        assert_eq!(Role::parse(Role::Admin.as_str()), Role::Admin);
        assert_eq!(Role::parse(Role::User.as_str()), Role::User);
        assert_eq!(Role::parse("nonsense"), Role::User);
    }

    #[test]
    fn legacy_record_without_role_reads_as_user() {
        // Simulate a pre-roles user record: only salt + hash, no `role` field.
        let path = tmp("legacy-role");
        let users = UserStore::open(&path).unwrap();
        {
            let mut tree = users.tree.lock().unwrap();
            let salt = random_id();
            let record = Value::Object(vec![
                ("salt".into(), Value::Str(salt.clone())),
                ("hash".into(), Value::Str(hash_password(&salt, "pw"))),
            ]);
            tree.insert(b"old", record.to_json().as_bytes()).unwrap();
            tree.commit().unwrap();
        }
        assert_eq!(users.role_of("old"), Some(Role::User));
        assert!(users.verify("old", "pw").unwrap(), "password still works");
    }

    #[test]
    fn passwordless_account_has_role_but_cannot_password_login() {
        let path = tmp("passwordless");
        let users = UserStore::open(&path).unwrap();
        users.ensure_account("a@b.com", Role::User).unwrap();
        assert_eq!(users.role_of("a@b.com"), Some(Role::User));
        // No hash stored → password login can never succeed.
        assert!(!users.verify("a@b.com", "").unwrap());
        assert!(!users.verify("a@b.com", "anything").unwrap());
    }

    // --- OTP store --------------------------------------------------------

    fn open_otp(name: &str) -> OtpStore {
        OtpStore::open(tmp(name)).unwrap()
    }

    #[test]
    fn otp_generate_then_verify_happy_path() {
        let store = open_otp("otp-happy");
        let log = LogDeliver::new();
        store.request("User@Example.com", &log).unwrap();

        // The code is delivered (keyed by the normalized email) and is 6 digits.
        let code = log.last_code("user@example.com").expect("code delivered");
        assert_eq!(code.len(), OTP_CODE_DIGITS);
        assert!(code.bytes().all(|b| b.is_ascii_digit()));

        // Email match is case/space-insensitive; correct code verifies once.
        assert_eq!(
            store.verify("  USER@EXAMPLE.COM ", &code).unwrap(),
            OtpOutcome::Verified
        );
        // The OTP is single-use: a second verify finds nothing.
        assert_eq!(
            store.verify("user@example.com", &code).unwrap(),
            OtpOutcome::NoCode
        );
    }

    #[test]
    fn otp_unknown_email_is_nocode() {
        let store = open_otp("otp-nocode");
        assert_eq!(
            store.verify("nobody@example.com", "000000").unwrap(),
            OtpOutcome::NoCode
        );
    }

    #[test]
    fn otp_expired_code_is_rejected_and_cleared() {
        let store = open_otp("otp-expired");
        // Seed a code that expired one second ago.
        store
            .seed_code("u@x.com", "123456", now_secs() - 1)
            .unwrap();
        assert_eq!(
            store.verify("u@x.com", "123456").unwrap(),
            OtpOutcome::Expired
        );
        // Expiry clears the record.
        assert_eq!(
            store.verify("u@x.com", "123456").unwrap(),
            OtpOutcome::NoCode
        );
    }

    #[test]
    fn otp_wrong_code_then_attempt_cap() {
        let store = open_otp("otp-cap");
        let log = LogDeliver::new();
        store.request("cap@x.com", &log).unwrap();
        let code = log.last_code("cap@x.com").unwrap();

        // The first OTP_MAX_ATTEMPTS-1 wrong guesses report WrongCode...
        for _ in 0..(OTP_MAX_ATTEMPTS - 1) {
            assert_eq!(
                store.verify("cap@x.com", "000000").unwrap(),
                OtpOutcome::WrongCode
            );
        }
        // ...the one that hits the cap, and every guess after, is locked out —
        // even the *correct* code.
        assert_eq!(
            store.verify("cap@x.com", "000000").unwrap(),
            OtpOutcome::TooManyAttempts
        );
        assert_eq!(
            store.verify("cap@x.com", &code).unwrap(),
            OtpOutcome::TooManyAttempts,
            "cap locks out even the right code"
        );
    }

    #[test]
    fn otp_request_overwrites_prior_code_and_resets_attempts() {
        let store = open_otp("otp-overwrite");
        let log = LogDeliver::new();
        store.request("re@x.com", &log).unwrap();
        // Burn an attempt against the first code.
        assert_eq!(
            store.verify("re@x.com", "000000").unwrap(),
            OtpOutcome::WrongCode
        );
        // Re-request: a new code with a fresh attempt counter.
        store.request("re@x.com", &log).unwrap();
        let code = log.last_code("re@x.com").unwrap();
        assert_eq!(
            store.verify("re@x.com", &code).unwrap(),
            OtpOutcome::Verified
        );
    }

    // --- cookie helpers ---------------------------------------------------

    #[test]
    fn parse_cookie_finds_named_value() {
        let header = "theme=dark; session=abc123; lang=en";
        assert_eq!(parse_cookie(header, "session").as_deref(), Some("abc123"));
        assert_eq!(parse_cookie(header, "theme").as_deref(), Some("dark"));
        assert_eq!(parse_cookie(header, "missing"), None);
    }

    #[test]
    fn parse_cookie_handles_single_pair_and_spacing() {
        assert_eq!(
            parse_cookie("session=xyz", "session").as_deref(),
            Some("xyz")
        );
        assert_eq!(
            parse_cookie("  session = spaced ", "session").as_deref(),
            Some("spaced")
        );
    }

    #[test]
    fn set_and_expire_cookie_have_the_right_attributes() {
        let set = set_cookie("tok");
        assert!(set.starts_with("session=tok"));
        assert!(set.contains("HttpOnly"));
        assert!(set.contains("Path=/"));
        assert!(set.contains("SameSite=Lax"));

        let clear = expired_cookie();
        assert!(clear.starts_with("session=;"));
        assert!(clear.contains("Max-Age=0"));
    }

    // --- login state machine (function level, no socket) ------------------

    fn open_state(name: &str) -> State {
        open_state_with_log(name).0
    }

    /// Open a fresh [`State`] plus a handle to its [`LogDeliver`] so tests can
    /// read the code it "sent".
    fn open_state_with_log(name: &str) -> (State, LogDeliver) {
        let dir = scratch_base().join(format!(
            "akurai-auth-state-{name}-{}-{}",
            std::process::id(),
            ID_COUNTER.fetch_add(1, Ordering::Relaxed)
        ));
        std::fs::create_dir_all(&dir).unwrap();
        // Clean any prior run's files so seeding is deterministic.
        for f in ["sessions.db", "users.db", "otp.db"] {
            let _ = std::fs::remove_file(dir.join(f));
        }
        let log = LogDeliver::new();
        let state = State::open_with_deliver(&dir, Arc::new(log.clone())).unwrap();
        (state, log)
    }

    fn req_with_cookie(cookie: Option<&str>) -> Request {
        let head = match cookie {
            Some(c) => format!("GET /account HTTP/1.1\r\nCookie: {c}\r\n\r\n"),
            None => "GET /account HTTP/1.1\r\n\r\n".to_string(),
        };
        Request::parse_head(head.as_bytes()).unwrap()
    }

    #[test]
    fn full_login_cycle_via_state() {
        let state = open_state("cycle");

        // No cookie → not authenticated.
        assert!(state.current_user(&req_with_cookie(None)).is_none());

        // Create a session for the seeded demo user (what login_submit does).
        let id = state.sessions.create(DEMO_USERNAME).unwrap();
        let cookie = set_cookie(&id);
        // The browser would send back just `name=value`; emulate that.
        let sent = format!("{COOKIE_NAME}={id}");
        let user = state
            .current_user(&req_with_cookie(Some(&sent)))
            .expect("authenticated");
        assert_eq!(user.username, DEMO_USERNAME);
        assert!(cookie.contains(&id));

        // Logout deletes the session; the same cookie now resolves to nobody.
        assert!(state.sessions.delete(&id).unwrap());
        assert!(state.current_user(&req_with_cookie(Some(&sent))).is_none());
    }

    #[test]
    fn account_redirects_when_unauthenticated() {
        let state = open_state("account-guard");
        let root = Path::new("nonexistent-frontend");
        let Reply::Response(resp) = account(&state, root, &req_with_cookie(None)) else {
            panic!("expected a buffered response");
        };
        assert_eq!(resp.status, 303);
        assert_eq!(
            resp.headers
                .iter()
                .find(|(k, _)| k.eq_ignore_ascii_case("location"))
                .map(|(_, v)| v.as_str()),
            Some("/login")
        );
    }

    #[test]
    fn demo_credentials_are_seeded_as_admin() {
        let state = open_state("seed");
        assert!(state.users.verify(DEMO_USERNAME, DEMO_PASSWORD).unwrap());
        assert_eq!(state.users.role_of(DEMO_USERNAME), Some(Role::Admin));
    }

    #[test]
    fn current_user_exposes_role_and_require_role_gates() {
        let state = open_state("role-gate");

        // Sign in as the seeded admin demo user.
        let admin_id = state.sessions.create(DEMO_USERNAME).unwrap();
        let admin_req = req_with_cookie(Some(&format!("{COOKIE_NAME}={admin_id}")));
        let admin = state.current_user(&admin_req).expect("admin signed in");
        assert_eq!(admin.role, Role::Admin);
        assert!(
            state.require_role(&admin_req, Role::Admin),
            "admin gates admin"
        );
        assert!(
            state.require_role(&admin_req, Role::User),
            "admin gates user"
        );

        // A passwordless OTP account is a plain user.
        state.ensure_account("plain@x.com").unwrap();
        let user_id = state.sessions.create("plain@x.com").unwrap();
        let user_req = req_with_cookie(Some(&format!("{COOKIE_NAME}={user_id}")));
        let user = state.current_user(&user_req).expect("user signed in");
        assert_eq!(user.role, Role::User);
        assert!(state.require_role(&user_req, Role::User), "user gates user");
        assert!(
            !state.require_role(&user_req, Role::Admin),
            "user is denied admin"
        );

        // Unauthenticated requests satisfy no role.
        let anon = req_with_cookie(None);
        assert!(!state.require_role(&anon, Role::User));
        assert!(!state.require_role(&anon, Role::Admin));
        assert_eq!(state.current_role(&anon), None);
    }

    #[test]
    fn otp_full_flow_via_state_signs_in() {
        let (state, log) = open_state_with_log("otp-state");
        let email = "golfer@example.com";

        // Request a code, then verify it through the State seam.
        state.request_otp(email).unwrap();
        let code = log.last_code(email).expect("code delivered");
        assert_eq!(
            state.verify_otp(email, &code).unwrap(),
            OtpOutcome::Verified
        );

        // A successful OTP login creates the account; it is a plain user.
        state.ensure_account(email).unwrap();
        let id = state.sessions.create(email).unwrap();
        let req = req_with_cookie(Some(&format!("{COOKIE_NAME}={id}")));
        let who = state.current_user(&req).expect("signed in via OTP");
        assert_eq!(who.username, email);
        assert_eq!(who.role, Role::User);
    }
}