- Rust 97.2%
- Nix 2.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
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>
|
||
| migrations | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| renovate.json | ||
♞ 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
AdminUseralias - Profile pictures — store and retrieve binary image data per user
- Multiple auth methods — cookie sessions,
Authorization: Bearer, HTTP Basic Auth - Pluggable storage — one
UserDBtype over a directory on disk, an in-memory filesystem, SQLite or PostgreSQL; or your ownBackend - 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_sessionreturnsNonefor an expired session and deletes it;
list_sessionsdrops expired sessions too.- Every session records
last_seen, written back byfrom_sessionat most once
a minute (LAST_SEEN_INTERVAL), so idle tracking costs at most one write per
session per minute. - The policy applies to
SessionKind::USERsessions. API keys do not expire;
end them withend_session/end_sessions. - Without a policy sessions never expire, as before.
- Time comes from the system clock; tests can install
authur::clock::ManualClockwithdb.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-Forhandling, 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 mostMAX_TRACKED_KEYS
usernames or addresses, evicting the least recently used. loginis unchanged and never throttled;login_fromwithout a configured
throttle behaves likeloginwith a typed error.- The
BasicAuthUserextractor 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(())
}
0002adds the nullableauthur_sessions.last_seencolumn; sessions from
before it read aslast_seen: Noneand 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
theNamespacedMigratorif 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_pathand timeouts belong to the pool you pass in. - Postgres needs an SQLx TLS feature (
sqlx/tls-rustlsorsqlx/tls-native-tls)
in your ownCargo.tomlfor 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,UserWithRoleandAdminUseraccept a proxy identity
whenever there is no validsessioncookie, so handlers do not change.ProxyUseraccepts 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
admingrantsAdminUser.
Without.sync_roles(true)groups are read once at provisioning and roles are
managed locally afterwards. db.user_from_proxy_headers(|name| ...)andProxyAuth::identitydo 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
passwdreturnsResult<(), PasswordError>instead ofResult<(), ()>.
Code using.is_ok(),.is_err()or?into a boxed error keeps compiling;
code naming the()error type must switch toPasswordError.Sessionhas a new public fieldlast_seen: Option<DateTime<Utc>>. Code
building aSessionwith a struct literal must add it;Session::newis
unchanged.Sessionsgainsend_sessionsandlogin_from. OnlyUserDBimplements it
in practice; any other implementor must add them.Backendgainstouch_sessionwith a default implementation, so custom
backends keep compiling.- SQL backends: run
migrate()once to apply0002. 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_hashstarting withb3:), 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_tokenstill 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_fromreturn typed errors; other legacy APIs still useOptionor 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.