- Rust 86.8%
- Shell 10.5%
- HTML 2.3%
- Just 0.3%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| .specify | ||
| docs | ||
| src | ||
| templates | ||
| .gitignore | ||
| anz.db-shm | ||
| anz.db-wal | ||
| anz.toml.example | ||
| Cargo.lock | ||
| Cargo.toml | ||
| cli-anz.toml.example | ||
| Dockerfile | ||
| justfile | ||
| LICENSE | ||
| README.md | ||
| renovate.json | ||
| REQUIREMENTS.md | ||
| rust-toolchain.toml | ||
anz
A minimal, personal OIDC provider written in Rust. Secures personal web applications with standard OpenID Connect authentication. Designed for a single operator managing a small set of users and applications.
Why
Keycloak is too much. Auth0 costs money. This is a single binary with a SQLite database that implements the OIDC spec well enough to put behind a reverse proxy and protect a handful of personal apps.
Features
- Multi-realm — isolated identity domains (users, clients, tokens)
- OIDC authorization code flow with PKCE
- Multi-algorithm signing — RS256 (default, required by Kubernetes) or EdDSA, per-realm, with concurrent keys during rotation
- Argon2id password hashing
- TOTP multi-factor auth — per-realm enforced or per-user opt-in; recovery codes; in-line enrollment during login
- Refresh token rotation with RFC 7009 revocation
- Confidential clients — optional client_secret for server-side apps (Forgejo, etc.)
- Client credentials grant (RFC 6749 §4.4) — per-client service accounts for machine-to-machine auth (CI/CD), with a configured token audience
- Groups claim —
groupsarray in ID tokens and UserInfo for RBAC (Kubernetes, etc.) - Rate limiting — per-IP login + per-user MFA attempt throttling
- Audit logging — JSON-line event log for login, token, and session activity
- Per-realm branding — customizable login page (colors, logo, CSS)
- Minimal login UI — server-rendered HTML, no JavaScript frameworks
- CLI admin — no admin web UI, just
anz realm/user/client/sessioncommands - SQLite — single file, embedded, no external database
Quick Start
cp anz.toml.example anz.toml
# edit anz.toml with your issuer URL
anz realm create myapp
anz user add --realm myapp --username alice --email alice@example.com
anz client add --realm myapp --client-id myapp-web \
--redirect-uri http://localhost:3000/callback # repeat --redirect-uri for more
anz serve
Integrating a Browser SPA
Server-side apps (Forgejo, etc.) validate tokens on the backend. A browser single-page app talks to anz directly, so it needs a public PKCE client and CORS.
-
Register a public client — no
--secret, so PKCE is enforced. Register both the production and local-dev redirect URIs on the sameclient_id:anz client add --realm myapp --client-id myapp-pwa \ --redirect-uri https://app.example.com/callback \ --redirect-uri http://localhost:5173/callback -
Allow the app's origin so the browser may fetch the OIDC endpoints cross-origin. In
anz.toml:cors_allowed_origins = ["https://app.example.com", "http://localhost:5173"] -
Run the PKCE auth-code flow in the browser:
const ISSUER = "https://auth.example.com", CLIENT = "myapp-pwa", REDIRECT = location.origin + "/callback"; const meta = await (await fetch(`${ISSUER}/realms/myapp/.well-known/openid-configuration`)).json(); // CORS // Start login: stash a PKCE verifier, then navigate (not fetch) to /authorize const verifier = randomString(); sessionStorage.setItem("v", verifier); const challenge = base64url(await sha256(verifier)); location.assign(`${meta.authorization_endpoint}?response_type=code&client_id=${CLIENT}` + `&redirect_uri=${REDIRECT}&scope=openid%20profile%20groups` + `&code_challenge=${challenge}&code_challenge_method=S256`); // On the callback page, exchange the code for tokens — no client_secret const code = new URLSearchParams(location.search).get("code"); const tokens = await (await fetch(meta.token_endpoint, { // CORS method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, client_id: CLIENT, redirect_uri: REDIRECT, code_verifier: sessionStorage.getItem("v") }), })).json(); // tokens.access_token / id_token / refresh_token — refresh later with grant_type=refresh_token
Audience: a resource server validating the access token checks
audagainst the realm URL (https://auth.example.com/realms/myapp), not theclient_id— see OIDC Endpoints.
For a complete working reference, see navinote's pwa/src/lib/auth.js.
Configuration
See anz.toml.example:
bind_address = "127.0.0.1:8080"
issuer_base_url = "https://auth.example.com"
database_path = "anz.db"
Deploy behind a TLS-terminating reverse proxy (nginx, caddy, etc.). Set issuer_base_url to your public HTTPS URL — Kubernetes and other OIDC consumers require HTTPS and will reject tokens from HTTP issuers.
Confidential clients authenticate via client_secret_post (secret in the POST body) or client_secret_basic (HTTP Basic auth header).
OIDC Endpoints
All endpoints are realm-scoped:
| Endpoint | Path |
|---|---|
| Discovery | GET /realms/{realm}/.well-known/openid-configuration |
| JWKS | GET /realms/{realm}/jwks |
| Authorize | GET /realms/{realm}/authorize |
| Token | POST /realms/{realm}/token |
| UserInfo | GET /realms/{realm}/userinfo |
| Password | POST /realms/{realm}/password |
| Revoke | POST /realms/{realm}/revoke |
Token audience (aud): Access tokens carry the realm URL
({issuer_base_url}/realms/{realm}) as aud, following the
RFC 9068 JWT-access-token profile — a
resource server should validate against this, not the client_id. ID tokens
use the client_id as aud, per standard OIDC.
CLI
anz realm create <name> [--key-type rs256|ed25519] # defaults to rs256
anz realm list
anz realm delete <name>
anz realm rotate-key --realm <r> --key-type rs256|ed25519
anz realm deactivate-key --realm <r> --kid <kid> # stops signing; kid still in JWKS
anz realm delete-key --realm <r> --kid <kid> # removes kid from JWKS entirely
anz realm set-mfa-required --realm <r> # force MFA for all users
anz realm set-mfa-required --realm <r> --required false
anz user add --realm <r> --username <u> --email <e> [--groups admin,dev]
anz user update-groups --realm <r> --username <u> --groups <g1,g2>
anz user enroll-mfa --realm <r> --username <u> # prints QR + recovery codes
anz user disable-mfa --realm <r> --username <u> # operator escape hatch
anz user list --realm <r>
anz user remove --realm <r> --username <u>
anz client add --realm <r> --client-id <id> --redirect-uri <uri> [--redirect-uri <uri> ...] [--secret]
anz client list --realm <r>
anz client remove --realm <r> --client-id <id>
anz session list --realm <r> --username <u>
anz session revoke --realm <r> --username <u>
anz session cleanup
anz serve
Docker
docker pull ghcr.io/navicore/anz:latest
docker run -v ./anz.toml:/etc/anz/anz.toml -v ./data:/data -p 8080:8080 \
ghcr.io/navicore/anz --config /etc/anz/anz.toml serve
Releasing
Create a GitHub release with a tag like v0.2.0. The workflow automatically:
- Runs CI checks
- Bumps
Cargo.tomlversion to match the tag and commits to main - Generates a CycloneDX SBOM from Cargo.lock
- Builds and pushes a Docker image to GHCR with SBOM and SLSA provenance attestations
- Signs the image and attaches the Cargo SBOM via Cosign (keyless, GitHub OIDC)
Required repo secret: PAT (GitHub token with contents: write).
Verify a release (replace tag with actual version, or use @sha256:... digest for strongest guarantee):
cosign verify \
--certificate-identity-regexp "https://github.com/navicore/anz/.github/workflows/release.yml@refs/tags/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/navicore/anz:0.3.0
cosign verify-attestation --type cyclonedx \
--certificate-identity-regexp "https://github.com/navicore/anz/.github/workflows/release.yml@refs/tags/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/navicore/anz:0.3.0
The identity uses refs/tags/.* because releases are triggered by tag creation — the OIDC token GitHub mints for the workflow records the tag ref, not main.
All GitHub Actions are pinned to commit SHA (not version tags) to prevent supply chain attacks via tag mutation.
Development
# run the same checks as CI (format, clippy, tests, release build)
just ci
# format + build + test
just dev
CI runs just ci — the justfile is the single source of truth. Linux on PRs, macOS on merge to main.
Requires just and a Rust toolchain.