authn authz in rust
  • Rust 86.8%
  • Shell 10.5%
  • HTML 2.3%
  • Just 0.3%
Find a file
2026-06-01 13:10:34 +00:00
.forgejo/workflows update rust 2026-05-25 08:37:57 -07:00
.specify ⏺ Compiles cleanly (only pre-existing warnings). Here's a summary of what was done and what you still need to do: 2026-02-25 15:04:29 -08:00
docs anz-admin 2026-05-30 06:45:47 -07:00
src zero trust 2026-05-30 07:34:36 -07:00
templates review response 2026-04-16 17:20:44 -07:00
.gitignore ⏺ Compiles cleanly (only pre-existing warnings). Here's a summary of what was done and what you still need to do: 2026-02-25 15:04:29 -08:00
anz.db-shm phase-1 2026-02-25 05:56:48 -08:00
anz.db-wal phase-1 2026-02-25 05:56:48 -08:00
anz.toml.example doc 2026-05-29 19:50:57 -07:00
Cargo.lock fix regression to ff 2026-06-01 06:07:39 -07:00
Cargo.toml fix regression to ff 2026-06-01 06:07:39 -07:00
cli-anz.toml.example zero trust 2026-05-30 07:34:36 -07:00
Dockerfile Update rust Docker tag to v1.96.0 2026-06-01 09:02:38 +00:00
justfile lockfile 2026-04-05 20:50:47 -07:00
LICENSE Add MIT License to the project 2026-02-25 19:26:10 -08:00
README.md client support 2026-05-25 11:32:13 -07:00
renovate.json renovate 2026-05-19 16:06:38 -07:00
REQUIREMENTS.md init 2026-02-24 21:48:08 -08:00
rust-toolchain.toml update rust 2026-05-25 08:37:57 -07:00

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 claimgroups array 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/session commands
  • 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.

  1. Register a public client — no --secret, so PKCE is enforced. Register both the production and local-dev redirect URIs on the same client_id:

    anz client add --realm myapp --client-id myapp-pwa \
      --redirect-uri https://app.example.com/callback \
      --redirect-uri http://localhost:5173/callback
    
  2. 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"]
    
  3. 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 aud against the realm URL (https://auth.example.com/realms/myapp), not the client_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:

  1. Runs CI checks
  2. Bumps Cargo.toml version to match the tag and commits to main
  3. Generates a CycloneDX SBOM from Cargo.lock
  4. Builds and pushes a Docker image to GHCR with SBOM and SLSA provenance attestations
  5. 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.