A standalone authentication + authorization gateway written in Rust (actix-web). It sits in front of an upstream application, handles everything related to who the caller is and what they're allowed to do, and reverse-proxies already-authenticated requests through to that upstream — injecting trusted identity headers along the way.
- authn — registration, password login, passwordless (magic link / one-time code) login, optional WebAuthn/passkey login, sessions, JWT issuance/refresh, password resets, and a small admin API over users and sessions.
- authz — a bitmask-based permission/role system (
grants) plus an admin API for granting and revoking individual permission bits. - proxy — once a request has an authenticated session, any route not
claimed by authn/authz is forwarded to an
UPSTREAMservice withX-User-Id,X-User-Email, andX-User-Nameheaders set from the session (stripping any client-supplied copies of those headers first).
- Architecture
- Requirements
- Configuration
- Database setup
- Running
- Permissions model
- API reference
- Project layout
┌───────────────────────────────────────────┐
│ actix-web │
client ── request ──▶│ /authn/* (public + session-protected) │
│ /authz/* (permission-gated admin API) │
│ /* (Permissions + SessionMiddle- │
│ ware) ──▶ Proxy ──▶ UPSTREAM │
└───────────────────────────────────────────┘
│
PostgreSQL
- Sessions are opaque UUIDs stored server-side (Postgres, cached with
moka) and handed to the client as an
HttpOnly/Secure/SameSite=Strictcookie namedsession. Session rows denormalizeusername,email, androleso the session store can answer identity + authorization checks without a join. - JWTs (
/authn/jwt,/authn/me/jwt) are available as a stateless alternative/complement to cookie sessions, with refresh-token rotation handled byJwtService. - Authorization is a 128-bit permission bitmask per user (
grants.role, stored as a UUID and reinterpreted asu128). Route-level permission requirements are declared once inpermissions.jsonand enforced by thePermissions<User>middleware fromactixutils. - Everything outside
/authnand/authzfalls through todefault_service, which is theProxy— so this binary can be dropped in front of an existing app to add auth without touching that app's code.
- Rust (2024 edition toolchain — see
Cargo.toml) - PostgreSQL
- The
viewsetandactixutilscrates, referenced as a local path dependency (../viewset) and a versioned crate respectively — make sureviewsetis checked out as a sibling directory if building from source. sqlx-clifor running migrations (cargo install sqlx-cli --no-default-features --features postgres)
The service reads configuration from environment variables (a .env file in
the working directory is loaded automatically via dotenv).
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
yes | Postgres connection string, e.g. postgres://user:pass@localhost/authnz |
signer.secret |
yes | HMAC secret used to sign JWTs (HS256) |
signer.aud |
yes | aud claim embedded in issued JWTs |
UPSTREAM |
yes | Base URL the proxy forwards unmatched requests to, e.g. http://localhost:3000 |
ADMIN |
yes, for authz "claim admin" | UUID of the user allowed to self-promote to a full-permission role via POST /authz/admin/claim |
PROXIES |
no | Comma-separated list of trusted proxy CIDRs, used by the passwordless rate limiter to resolve real client IPs |
RUST_LOG |
no | tracing-subscriber env filter (defaults to info) |
The server binds to 127.0.0.1:8080.
Migrations live under migrations/ and are plain SQL,
managed with sqlx-cli:
export DATABASE_URL=postgres://user:pass@localhost/authnz
sqlx database create
sqlx migrate runThis creates:
users— credentials and profile fields (username,email,password_hash,email_confirmed, timestamps)refresh_tokens— hashed refresh tokens for the JWT flowpassword_resets— hashed, single-use password reset tokenspasskey_credentials— registered WebAuthn credentials (only used when built with thepasskeyfeature)sessions— server-side session records (denormalized user info + issuing IP + expiry)grants— one row per user mappingto_id → role(the permission bitmask)
# core build (password + passwordless auth, no WebAuthn)
cargo run
# with WebAuthn/passkey support
cargo run --features passkeyOn startup the service also loads permissions.json
and will refuse to start if it's missing or invalid.
Authorization is a single u128 bitmask per user, persisted as a UUID in
grants.role. Each protected admin route is mapped to a bit position in
permissions.json:
{ "method": "GET", "url": "/authn/me/admin/users", "bit_id": 100 }POST /authz/admin/grantsets bitpermissionfortarget(OR).POST /authz/admin/denyclears bitpermissionfortarget(AND NOT) and revokes all of that user's active sessions, so a denied permission takes effect immediately rather than waiting for the session to expire.POST /authz/admin/claimlets exactly one bootstrap user — the UUID in theADMINenv var — grant themselves the all-ones role (u128::MAX) once, to get the system off the ground.
grant/deny themselves are not gated by the Permissions middleware in
this codebase (they sit above /authz's permission-gated admin/* scope) —
treat access to them as a deployment-level concern (network policy, mutual
TLS, etc.) rather than an in-app one.
All request/response bodies are JSON unless noted. Routes under /me require
a valid session cookie; routes under /me/admin and /authz/admin/grants
additionally require the caller's role to have the relevant permission bit
set (see permissions.json).
| Method | Path | Description |
|---|---|---|
| POST | /register |
Create a user. Body: { username, email, password } |
| POST | /login/email |
Log in with email + password. Body: { identifier, password }. Sets the session cookie |
| POST | /login/username |
Log in with username + password |
| POST | /request_password_reset |
Body: { email }. Always returns 200; emits an event carrying the reset token for an out-of-band mailer to consume |
| POST | /confirm_password_reset |
Body: { token, new_password }. Revokes all existing sessions and refresh tokens for that user |
These routes are wrapped in a ResponseEqualizer that normalizes response
timing, to reduce the ability to distinguish "wrong password" from
"no such user" via timing side-channels.
| Method | Path | Description |
|---|---|---|
| POST | /jwt |
Exchange the current session for a JWT access/refresh pair |
| POST | /logout |
Destroy the current session |
| GET | /account |
Echoes the authenticated user's ID (sanity-check endpoint) |
| POST | /change_password |
Body: { current_password, new_password }. Revokes all sessions and refresh tokens, then issues a fresh session for the calling device |
| GET | /sessions |
List the caller's own active sessions |
| DELETE | /delete_session/{id} |
Delete one of the caller's own sessions (403 if it belongs to someone else) |
| Method | Path | Description |
|---|---|---|
| POST | /refresh |
Rotate a refresh token for a new access/refresh pair |
| POST | /logout |
Revoke a specific refresh token |
Rate-limited (100 requests/min per client IP, trusting PROXIES for IP
resolution).
| Method | Path | Description |
|---|---|---|
| POST | /challenge/email |
Body: { email }. Emits an event with a magic-link token for an out-of-band mailer |
| GET | /challenge/username/{username} |
Same, keyed by username |
| GET | /confirm_link/{link} |
Confirm via magic link, marks the email confirmed, and starts a session |
| POST | /confirm_token |
Body: { token, nonce } — confirm via a short numeric code + nonce instead of a link |
| Method | Path | Description |
|---|---|---|
| POST | /register/start |
Begin WebAuthn credential registration (session required) |
| POST | /register/finish |
Complete registration |
| GET | /register |
List the caller's registered credentials |
| DELETE | /register/{id} |
Remove a credential |
| POST | /login/start |
Begin WebAuthn login (public — no session yet) |
| POST | /login/finish |
Complete WebAuthn login and start a session |
Standard CRUD ViewSets, permission-gated per method/route as declared in
permissions.json:
/users,/users/{id}— manage user records/sessions,/sessions/{id}— manage any user's sessions
| Method | Path | Description |
|---|---|---|
| POST | /admin/claim |
Bootstrap: the ADMIN-configured user grants themselves full permissions |
| POST | /admin/grant |
Body: { target, permission, .. }. Sets a permission bit for target |
| POST | /admin/deny |
Clears a permission bit for target and revokes their sessions |
| GET/POST/GET/DELETE/PATCH | /admin/grants[/{id}] |
ViewSet over raw grant rows |
Any request that doesn't match a route above passes through
SessionMiddleware + Permissions<User> and is proxied to UPSTREAM, with
X-User-Id / X-User-Email / X-User-Name set from the session.
src/
├── main.rs # wiring: DB pool, session store, module composition, HTTP server
├── proxy.rs # reverse proxy to UPSTREAM with identity header injection
├── models.rs # the app-wide `User` (session/authz-facing) type
├── authn/
│ ├── config.rs # route composition for the authn module
│ ├── handlers.rs # register/login/logout/password reset/JWT handlers
│ ├── middleware.rs # SessionMiddleware
│ ├── session.rs # Session<T> extractor
│ ├── admin.rs # ViewSets for admin user/session management
│ ├── domain/ # user service, JWT service, session service
│ ├── passwdless/ # magic-link / one-time-code login
│ └── passkey/ # WebAuthn registration & login (feature-gated)
└── authz/
├── config.rs # route composition for the authz module
├── handlers.rs # grant/deny/claim admin endpoints
├── services.rs # bitmask grant/deny logic
└── admin.rs # ViewSet over raw grant rows
migrations/ # sqlx migrations (users, sessions, tokens, grants, passkeys)
permissions.json # method+URL → permission-bit map, loaded at startup