- Rust 96%
- Nix 4%
| 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
`UserDB` is no longer generic over a `vfs::FileSystem`. It owns a type-erased `Backend` — the trait every store implements — so the application state and every extractor see one type whichever store is configured. The extractors previously hard-coded `UserDB<PhysicalFS>`, which made the in-memory store unusable behind a framework. Backends: - `VfsBackend<T>`: the existing on-disk layout, byte-for-byte, so current data directories keep working. - `SqlBackend` (`SqliteBackend`, `PostgresBackend`, features `sqlite` / `postgres`): three `authur_*` tables on a caller-owned SQLx pool, schema embedded and applied explicitly through `NamespacedMigrator` on `authur_migrations`, never the host's `_sqlx_migrations`. User deletion is one transaction. The SQL is shared between drivers. Proxy auth (`ProxyAuth`, attached with `UserDB::with_proxy_auth`): trusted identity headers from an authenticating reverse proxy (Remote-User/Groups/Email/Name by default; oauth2-proxy preset), with an optional constant-time shared-secret header, just-in-time provisioning of password-less users, and optional role sync from groups. The cookie extractors fall back to the proxy identity when there is no valid session; `ProxyUser` accepts only the proxy. `User.password` may now be empty, meaning no local password; `verify_pw` is false for such users. Tests: `tests/backends.rs` runs one behavioural suite against memory, SQLite and PostgreSQL (the latter ignored unless AUTHUR_TEST_POSTGRES_URL is set; verified against PostgreSQL 18). `tests/proxy.rs` covers header parsing, the secret and provisioning policy. Co-Authored-By: Claude Fable 5.1 <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 passwords (bcrypt hashed)
- Session tokens — login, session creation, session listing and termination
- 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?;
// 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::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;
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(())
}
- 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 — 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])
}
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
- The token is stored on disk as a bcrypt hash; the plaintext is never persisted
- Sessions are looked up by a blake3 hash of the token (deterministic ID)
- Verification requires bcrypt comparison against the stored hash
Limitations / Not Yet Implemented
- 2FA / TOTP — not implemented
- Rate limiting — no built-in brute-force protection on login
- Profile picture validation — no size or MIME-type checks
- Blocking operations — bcrypt 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 — legacy APIs still use
Option,(), or panic on some storage failures; typed fallible storage APIs are future work - 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
- Session lifetime — no automatic expiration or revocation on password change; explicitly revoke sessions according to your application's policy
- 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, and authorization around database mutation methods.
The database API is a trusted administrative interface, not an authorization boundary.