♞ auth
  • Rust 97.2%
  • Nix 2.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
JMARyA 509531e9da
Some checks failed
moira/threads moira/threads — definitions compiled
moira/flake/env.path.678f50d24738eb94@x86_64-linux env.path.678f50d24738eb94 — built
moira/flake/env.path.c7ca2c6462f7919a@x86_64-linux env.path.c7ca2c6462f7919a — built
moira/flake/env.path.e63a39106bd2cfa8@x86_64-linux env.path.e63a39106bd2cfa8 — built
moira/flake/packages.build@x86_64-linux packages.build — queued
moira/flake/packages.default@x86_64-linux packages.default — queued
moira/flake/checks.fmt@x86_64-linux checks.fmt — queued
moira/flake/checks.test@x86_64-linux checks.test — queued
moira/ci moira/ci — succeeded
moira/flake-staleness moira/flake-staleness — succeeded
moira/audit moira/audit — failed
Session expiry, revoke-all, set_password, login throttling, fast verifiers
Generic auth gaps, on every backend (VFS on disk and in memory, SQLite,
PostgreSQL) and covered by the backend conformance suite:

- SessionPolicy { lifetime, idle_timeout } via UserDB::with_session_policy.
  from_session refuses and deletes expired USER sessions; list_sessions
  drops them. Session.last_seen (migration 0002 on both SQL backends) is
  touched at most once a minute through Backend::touch_session, which
  never recreates an ended session and has a default implementation.
  Time comes from an injectable clock (authur::clock, UserDB::with_clock).
- Sessions::end_sessions(username, except_id, kind) -> usize.
- UserDB::set_password and PasswordError; passwd now returns it too.
- LoginThrottle { per_user, per_ip } via UserDB::with_login_throttle and
  Sessions::login_from -> Result<_, LoginError>. In-memory sliding
  windows, LRU-bounded, attempts counted on admission so concurrent
  requests cannot overshoot. login is unchanged and unthrottled.
- New sessions store a domain-separated blake3 verifier ("b3:" prefix)
  instead of bcrypt, so from_session costs no bcrypt. Legacy bcrypt
  records keep verifying until they expire or are ended.

README documents all of it and drops the rate-limiting and session
lifetime limitations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:18:24 +02:00
migrations Session expiry, revoke-all, set_password, login throttling, fast verifiers 2026-10-24 16:18:24 +02:00
src Session expiry, revoke-all, set_password, login throttling, fast verifiers 2026-10-24 16:18:24 +02:00
tests Session expiry, revoke-all, set_password, login throttling, fast verifiers 2026-10-24 16:18:24 +02:00
.gitignore init 2025-05-05 12:00:49 +02:00
Cargo.lock Add SQLx migration infra with history-table isolation; update deps 2026-10-15 22:25:04 +02:00
Cargo.toml Optimise bcrypt and blowfish in the dev profile 2026-10-24 16:18:24 +02:00
flake.lock Wire crate into moira CI via moira-modules fromProject 2026-10-16 20:50:33 +02:00
flake.nix Wire crate into moira CI via moira-modules fromProject 2026-10-16 20:50:33 +02:00
README.md Session expiry, revoke-all, set_password, login throttling, fast verifiers 2026-10-24 16:18:24 +02:00
renovate.json Add renovate.json 2026-10-15 21:00:09 +00:00

♞ Authur

authur is a lightweight, framework-agnostic Rust authentication library providing user management, session handling, CSRF protection, and role-based access control (RBAC). It stores its data on a pluggable backend — a directory, memory, SQLite or PostgreSQL — and can defer the actual login to an authenticating reverse proxy. It integrates with Rocket and Axum via optional feature flags.

Builds on current stable Rust (edition 2024). Framework integrations and SQL backends are optional; the default build has no web framework, database driver or async runtime dependency.

Features

  • User management — create users, find users, change or reset passwords (bcrypt hashed)
  • Session tokens — login, session creation, listing and termination, revoke-all, and expiry by age and idleness
  • Login throttling — optional per-user and per-address limits on failed logins
  • API keys — named bearer-token sessions for programmatic access
  • CSRF protection — per-user CSRF tokens with HTMX-friendly JS injection
  • Role-based access control — arbitrary roles, built-in AdminUser alias
  • Profile pictures — store and retrieve binary image data per user
  • Multiple auth methods — cookie sessions, Authorization: Bearer, HTTP Basic Auth
  • Pluggable storage — one UserDB type over a directory on disk, an in-memory filesystem, SQLite or PostgreSQL; or your own Backend
  • Proxy auth — trust Remote-User-style headers from Authelia, Authentik, oauth2-proxy, Caddy/Traefik forward-auth and friends, with just-in-time provisioning
  • Framework extractors — request guards / extractors for Rocket and Axum

Installation

[dependencies]
authur = { path = "...", features = ["axum", "sqlite"] }

Available features (default: none):

Feature Adds
axum Axum extractors
rocket Rocket request guards
sqlite authur::sql::SqliteBackend via SQLx
postgres authur::sql::PostgresBackend via SQLx

Quick Start

Initialize the database

UserDB is one type whichever backend sits underneath, so application state
and extractors do not change when storage does.

use authur::{UserDB, Roles};

// A directory on disk
let db = UserDB::new("./data/users").await;

// In memory, for tests
let db = UserDB::new_memory().await;

// SQLite or PostgreSQL (features `sqlite` / `postgres`): see "SQL backends"
let db = UserDB::with_backend(backend);

// Optional provisioning: creates admin/admin if the user does not exist.
// Change the default password before exposing the application.
db.ensure_admin().await;

User management

use authur::{UserDB, Roles};

// Create a user
let user = db.create("alice".to_string(), "hunter2", Roles::default()).await;

// Create a user with roles
let roles = Roles::default().with("admin").with("moderator");
let admin = db.create("bob".to_string(), "s3cur3", roles).await;

// Look up a user
let user = db.find("alice").await;

// List all usernames
let names: Vec<String> = db.find_all().await;

// Change password (requires old password)
db.passwd("alice", "hunter2", "newpass").await?;

// Set a password without the old one (admin action, password reset)
db.set_password("alice", "newpass").await?;

// Delete a user and all their data (sessions, profile picture, CSRF token)
// Returns true if the user existed, false if not found
db.delete("alice").await;

Sessions & login

use authur::{SessionKind, Sessions};

// Login — returns (Session, Roles) on success
let (session, roles) = db.login("alice", "hunter2").await.unwrap();

// The plain token is only available immediately after creation
println!("token: {}", session.token);

// Validate a token and retrieve the associated user
let user = db.from_session(token).await;

// List all active sessions for a user
let sessions = db.list_sessions("alice").await;

// Terminate a session by its ID
db.end_session(&session.id).await;

// Terminate all of a user's sessions; returns how many were ended
db.end_sessions("alice", None, None).await;
// ...except the current one, and only browser sessions (API keys stay)
db.end_sessions("alice", Some(&session.id), Some(SessionKind::USER)).await;

passwd and set_password return Result<(), PasswordError>
(UnknownUser, IncorrectPassword for passwd only, TooLong beyond 72
bytes, Hash, Storage). Neither ends existing sessions; call end_sessions
if your policy wants a password change to log the user out elsewhere.

Session expiry

use std::time::Duration;
use authur::SessionPolicy;

let db = db.with_session_policy(SessionPolicy {
    lifetime: Duration::from_secs(14 * 24 * 3600),          // from login
    idle_timeout: Some(Duration::from_secs(3 * 24 * 3600)),  // since last use
});
  • from_session returns None for an expired session and deletes it;
    list_sessions drops expired sessions too.
  • Every session records last_seen, written back by from_session at most once
    a minute (LAST_SEEN_INTERVAL), so idle tracking costs at most one write per
    session per minute.
  • The policy applies to SessionKind::USER sessions. API keys do not expire;
    end them with end_session / end_sessions.
  • Without a policy sessions never expire, as before.
  • Time comes from the system clock; tests can install
    authur::clock::ManualClock with db.with_clock(clock.clone()) and
    clock.advance(..) instead of sleeping.

Login throttling

use std::time::Duration;
use authur::{LoginError, LoginThrottle, Sessions};

let db = db.with_login_throttle(LoginThrottle {
    per_user: (5, Duration::from_secs(15 * 60)),   // 5 failures per 15 min per username
    per_ip: (50, Duration::from_secs(15 * 60)),    // 50 failures per 15 min per address
});

match db.login_from("alice", "hunter2", Some(client_ip)).await {
    Ok((session, roles)) => { /* set the cookie */ }
    Err(LoginError::Invalid) => { /* wrong username or password */ }
    Err(LoginError::Throttled { retry_after }) => { /* 429, Retry-After */ }
}
  • An attempt over either limit is refused before the password is checked, even
    if the password is right, and costs no bcrypt. Attempts are counted when
    admitted, so concurrent requests cannot overshoot the limit.
  • A successful login clears that username's failures, not the address's.
  • Unknown usernames and users without a password count like wrong passwords.
  • IPv6 clients count per /64; IPv4-mapped addresses as IPv4. Pass the real
    client address: behind a reverse proxy, that comes from your trusted
    X-Forwarded-For handling, not from the socket.
  • State is in memory, shared by clones of the UserDB, lost on restart and
    not shared between processes. Each table tracks at most MAX_TRACKED_KEYS
    usernames or addresses, evicting the least recently used.
  • login is unchanged and never throttled; login_from without a configured
    throttle behaves like login with a typed error.
  • The BasicAuthUser extractor checks the password on every request and is
    not throttled; prefer sessions or API keys where brute force matters.

API keys

use authur::Sessions;

// Create a named API key
let api_key = db.api_key("my-service", "alice").await;
println!("key: {}", api_key.token); // store this — it won't be shown again

// Authenticate the same way as a regular session token
let user = db.from_session(api_key.token).await;

CSRF protection

use authur::csrf::CSRF;

// Get or create the current CSRF token for a user
let token = db.get_csrf("alice").await;

// Verify and rotate (returns false if invalid)
let valid = db.verify_csrf(&submitted_token, "alice").await;

// Reset to a new token
db.reset_csrf("alice").await;

// Generate a <script> snippet for HTMX — updates all elements with class "csrf"
let snippet = db.update_csrf("alice").await;

Profile pictures

use authur::profile_pic::ProfilePic;

let bytes: Vec<u8> = std::fs::read("avatar.png").unwrap();
db.set_profile_pic(bytes, "alice").await;

let pic: Option<Vec<u8>> = db.profile_pic("alice").await;

Role-based access control

use authur::Roles;

let roles = Roles::default().with("editor").with("admin");

roles.has_role("editor"); // true
roles.has_role("admin");  // true
roles.has_role("viewer"); // false

SQL backends

With the sqlite or postgres feature, authur::sql stores everything in
three tables — authur_users, authur_sessions, authur_pictures — on a
caller-owned SQLx pool. The schema ships inside the crate; applying it is
explicit and never happens on startup.

use authur::{UserDB, sql::SqliteBackend};

#[::tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = sqlx::SqlitePool::connect("sqlite://auth.db?mode=rwc").await?;
    let backend = SqliteBackend::new(pool);   // PostgresBackend::new(pg_pool)
    backend.migrate().await?;                  // idempotent
    let db = UserDB::with_backend(backend);
    // ...
    Ok(())
}
  • 0002 adds the nullable authur_sessions.last_seen column; sessions from
    before it read as last_seen: None and get one on first use.
  • Migration history lives in authur_migrations, never in the host's
    _sqlx_migrations, so an application can run its own SQLx migrations on the
    same pool. SqliteBackend::migrator() / PostgresBackend::migrator() expose
    the NamespacedMigrator if you want to drive it yourself.
  • Only the three authur_* tables are touched. Name your own tables differently.
  • Users, sessions and pictures are written in single statements, deleting a user
    in one transaction, so the SQL backends do not share the VFS backend's
    concurrency caveats.
  • Connection limits, TLS, search_path and timeouts belong to the pool you pass in.
  • Postgres needs an SQLx TLS feature (sqlx/tls-rustls or sqlx/tls-native-tls)
    in your own Cargo.toml for encrypted connections.

Custom backends

Implement authur::Backend — ten async methods for loading and storing users,
sessions and pictures, with no hashing or policy in them, plus touch_session,
whose default reloads and re-stores the session (override it with an in-place
update if you can) — and pass it to
UserDB::with_backend. tests/backends.rs is the conformance suite; run it
against a new backend by adding a constructor to the suite! list.

Proxy auth

When an identity-aware reverse proxy (Authelia, Authentik, oauth2-proxy,
Pomerium, Caddy / Traefik forward-auth, Cloudflare Access, …) handles the
login — password, OIDC, passkeys, MFA — the application only needs to trust the
headers that proxy adds. ProxyAuth says which headers:

use authur::{UserDB, ProxyAuth};

let db = UserDB::new("./data").await.with_proxy_auth(
    ProxyAuth::new()                              // Remote-User / Remote-Groups / Remote-Email / Remote-Name
        .secret("X-Proxy-Secret", std::env::var("PROXY_SECRET")?)
        .sync_roles(true),                        // roles follow the proxy's groups
);

// oauth2-proxy's X-Forwarded-* header names
let db = db.with_proxy_auth(ProxyAuth::oauth2_proxy());

With proxy auth configured:

  • UserAuth, MaybeUser, UserWithRole and AdminUser accept a proxy identity
    whenever there is no valid session cookie, so handlers do not change.
  • ProxyUser accepts only the proxy and rejects everything else.
  • An unknown username is provisioned as a local user with no password and the
    proxy's groups as roles (.provision(false) to reject unknown users instead).
    Such users cannot log in with a password or Basic auth, but get sessions, API
    keys, CSRF tokens and profile pictures like anyone else.
  • Groups map to roles by name, so a proxy group admin grants AdminUser.
    Without .sync_roles(true) groups are read once at provisioning and roles are
    managed locally afterwards.
  • db.user_from_proxy_headers(|name| ...) and ProxyAuth::identity do the same
    outside a framework extractor.

Trust. The headers are plain text. This is safe only if every request reaches
the application through the proxy: bind to localhost or a private network the
proxy alone can reach, set a shared secret, and make sure the proxy strips any
client-supplied copies of the identity headers (the proxies above do). An
application reachable directly with proxy auth enabled lets anyone become anyone.

A native OIDC client is not included; the proxies above implement OIDC and
present its result as headers, which is what this integrates with.

Migrations for your own tables

The authur::migrations module (features sqlite / postgres) is the
history-table isolation the SQL backends are built on, and it works for any
SQLx migration set:

use sqlx::migrate::Migrator;
use authur::migrations::NamespacedMigrator;

let migrations = NamespacedMigrator::with_table(Migrator::new("./migrations").await?, "myapp_plugin_migrations")?;
migrations.run_sqlite(&pool).await?; // or run_postgres(&pool)
  • Pick a history table name once; it must be a simple lowercase identifier and
    never _sqlx_migrations.
  • Separate history tables do not isolate your tables: also use distinct table
    names from the host's migrations.

Framework Extractors

Extractors pull authenticated users from incoming requests. They require a UserDB to be registered as application state.

Type Auth method Failure
UserAuth session cookie, else proxy headers if configured 401
APIUser Authorization: Bearer <token> 401
BasicAuthUser Authorization: Basic <b64> 401
ProxyUser proxy headers only 401
MaybeUser like UserAuth, optional never — returns Anonymous
UserWithRole<R> like UserAuth + role check 401 unauthenticated / 403 wrong role
AdminUser alias for UserWithRole<AdminRole> 401 unauthenticated / 403 wrong role

Axum example

use authur::{UserDB, UserAuth, AdminUser};
use axum::{Router, routing::get, extract::State};

#[derive(Clone)]
struct AppState {
    db: UserDB,
}

impl axum::extract::FromRef<AppState> for UserDB {
    fn from_ref(state: &AppState) -> Self { state.db.clone() }
}

async fn profile(UserAuth(user): UserAuth) -> String {
    format!("Hello, {}!", user.username)
}

async fn admin_panel(user: AdminUser) -> String {
    format!("Admin: {}", user.0.username)
}

let db = UserDB::new("./data").await;
let app = Router::new()
    .route("/profile", get(profile))
    .route("/admin", get(admin_panel))
    .with_state(AppState { db });

Rocket example

use authur::{UserDB, UserAuth, AdminUser};

#[rocket::get("/profile")]
async fn profile(user: UserAuth) -> String {
    format!("Hello, {}!", user.0.username)
}

#[rocket::get("/admin")]
async fn admin_panel(user: AdminUser) -> String {
    format!("Admin: {}", user.0.username)
}

#[rocket::launch]
async fn rocket() -> _ {
    let db = UserDB::new("./data").await;
    rocket::build()
        .manage(db)
        .mount("/", rocket::routes![profile, admin_panel])
}

Upgrading: sessions, passwords and throttling

  • passwd returns Result<(), PasswordError> instead of Result<(), ()>.
    Code using .is_ok(), .is_err() or ? into a boxed error keeps compiling;
    code naming the () error type must switch to PasswordError.
  • Session has a new public field last_seen: Option<DateTime<Utc>>. Code
    building a Session with a struct literal must add it; Session::new is
    unchanged.
  • Sessions gains end_sessions and login_from. Only UserDB implements it
    in practice; any other implementor must add them.
  • Backend gains touch_session with a default implementation, so custom
    backends keep compiling.
  • SQL backends: run migrate() once to apply 0002. VFS data directories need
    no change.
  • Existing sessions stay valid. Existing bcrypt session records are verified
    with bcrypt until they expire or are ended; new ones use the fast verifier.

Migrating from UserDB<T>

UserDB is no longer generic: UserDB<PhysicalFS> and UserDB<MemoryFS> become
UserDB, and vfs::PhysicalFS no longer appears in application code. The
constructors, the on-disk layout and every method keep their signatures;
FromRef impls and Rocket .manage(db) calls only lose the type parameter.
User.password may now be empty, meaning "no local password" — verify_pw is
false for such users and User::has_password() tells them apart.

Stable role guards: migration

Replace nightly-only UserWithRole<"editor"> with a marker type:

use authur::{Role, UserWithRole};

struct Editor;
impl Role for Editor {
    const NAME: &'static str = "editor";
}
type EditorUser = UserWithRole<Editor>;

Access the authenticated user through .0. AdminUser remains available as a
built-in alias. Direct tuple construction is replaced by UserWithRole::new(user),
which checks role membership. BasicAuthUser and SessionKind are now exported at
the crate root.

Usernames must be nonempty filename components: ., .., separators, colons,
and control characters are rejected. Passwords longer than 72 bytes are
rejected instead of silently truncated by bcrypt. Existing bcrypt hashes and
session records remain readable. Debug output omits password hashes and tokens;
User serialization still includes its password hash for persistence—do not
serialize a User directly into an HTTP response.

Session Security

  • Session tokens are generated with 64 bytes of CSPRNG entropy
  • Sessions are looked up by a blake3 hash of the token (deterministic ID)
  • The token is verified against a separate, domain-separated blake3 hash
    (secret_hash starting with b3:), compared in constant time; the plaintext
    is never persisted. A 512-bit random token cannot be brute-forced, so a slow
    hash would only add CPU to every request: verifying a session costs no bcrypt
  • Sessions stored by earlier versions carry a bcrypt verifier; they keep
    working (at bcrypt cost per request) until they expire or are ended.
    hash_session_token still produces that legacy format
  • Optional expiry by age and idleness (see "Session expiry")

Limitations / Not Yet Implemented

  • 2FA / TOTP — not implemented
  • Profile picture validation — no size or MIME-type checks
  • Blocking operations — bcrypt (password checks and changes, and legacy session records) and the VFS backend do synchronous work behind the async API; offload calls from latency-sensitive executor threads. The SQL backends are properly async apart from bcrypt.
  • Error handling — password changes and login_from return typed errors; other legacy APIs still use Option or panic on some storage failures
  • Atomicity (VFS backend) — user creation and session/index writes are not transactional or safe under concurrent writers, including clones in one process; serialize mutations externally or use an SQL backend
  • Login throttle scope — counters are per process and in memory; several application instances each enforce their own limits
  • CSRF scope — tokens are per-user, process-local, and rotate on successful verification; parallel legitimate submissions may require a retry
  • Filesystem trust (VFS backend) — use a private database directory; filename validation does not defend against attacker-controlled symlinks
  • Proxy auth trust — header auth is only as safe as the network path; see "Proxy auth"
  • OIDC — no native OIDC client; use an OIDC-capable proxy

Applications must enforce HTTPS, secure/HttpOnly/SameSite cookie settings, request
size limits, rate limiting beyond login, and authorization around database mutation methods.
The database API is a trusted administrative interface, not an authorization boundary.