OAuth 2.1 server implementation in Rust

Efren Yevale Varela 0d522a39e8 Migrated unit tests from per-test containers to shared test services vor 3 Wochen
.cargo bbab988ffd Updated fuzz, miri, and mutant workflows vor 3 Wochen
.clusterfuzzlite bbab988ffd Updated fuzz, miri, and mutant workflows vor 3 Wochen
.config 7f3db91ac6 Hardened test vor 3 Wochen
.github 96502df740 Changed docker registry behavior vor 3 Wochen
.sqlx bf931f10f7 Security fixes vor 3 Wochen
.well-known 3e8f4d32d1 Security hardening vor 3 Wochen
deploy 232be93493 Removed pin-digest vor 3 Wochen
docs 0d522a39e8 Migrated unit tests from per-test containers to shared test services vor 3 Wochen
fuzz 362bd12d95 Pinned channel vor 3 Wochen
migrations 50269aa5bf Added encryption vor 4 Wochen
scripts 232be93493 Removed pin-digest vor 3 Wochen
src 0d522a39e8 Migrated unit tests from per-test containers to shared test services vor 3 Wochen
templates 7c50a23515 Initial commit vor 1 Monat
tests 35d12e87ee Updated database versions vor 3 Wochen
.codegraph 7c50a23515 Initial commit vor 1 Monat
.dockerignore 81769a4f70 Change cache handling vor 3 Wochen
.gitignore 81769a4f70 Change cache handling vor 3 Wochen
AGENTS.md 0d522a39e8 Migrated unit tests from per-test containers to shared test services vor 3 Wochen
Cargo.lock 837c400c9f Added styles and improved tests vor 1 Monat
Cargo.toml aca691346b Updated references vor 4 Wochen
Dockerfile 3ad38f73cc Updated components vor 3 Wochen
LICENSE.md 7c50a23515 Initial commit vor 1 Monat
README.md 0d522a39e8 Migrated unit tests from per-test containers to shared test services vor 3 Wochen
RUNBOOK.md c3d29f3802 Updated documentation vor 3 Wochen
SECURITY.md 3e8f4d32d1 Security hardening vor 3 Wochen
compose.test.yaml 0d522a39e8 Migrated unit tests from per-test containers to shared test services vor 3 Wochen
compose.yaml 35d12e87ee Updated database versions vor 3 Wochen
deny.toml 7c50a23515 Initial commit vor 1 Monat
docker-bake.hcl 4197d93e0b Fixed image publishing vor 3 Wochen
rust-toolchain.toml 7c50a23515 Initial commit vor 1 Monat
rustfmt.toml 7c50a23515 Initial commit vor 1 Monat

README.md

d-OAuth-Server

An OAuth 2.1-only authorization server written in Rust. It is deliberately not backwards compatible with OAuth 2.0: PKCE is mandatory, the implicit and resource-owner-password grants do not exist, bearer-in-query is rejected, and redirect URIs match exactly.

It implements the authorization-code, refresh-token, client-credentials, device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic client registration, introspection, and revocation. Tokens are opaque and hashed at rest. All durable domain state is in PostgreSQL and all ephemeral state is in Redis, so replicas are interchangeable.

Prerequisites

  • Rust stable, as pinned by rust-toolchain.toml.
  • PostgreSQL 16 or newer with the citext extension available (the shipped compose.yaml pins postgres:18-alpine).
  • Redis 8 or newer, with persistence enabled and eviction off.
  • A TLS certificate and key for the public listener.
  • Docker, for the container image, for the shared test Postgres and Redis in compose.test.yaml, and for the handful of suites that still use testcontainers because they need Redis nodes of their own (the keep-list is under Tests).
  • cargo-nextest for the test suite: cargo install cargo-nextest --locked.

Build

cargo build --locked
cargo build --locked --release

Quick start

The server reads configuration from D_OAUTH_* environment variables. Export a development configuration (values here are placeholders; see docs/CONFIGURATION.md for every key). The ports below are the local-dev defaults (Postgres 5432, Redis 6379); the shared test services use 5433/6380 (see Tests).

export D_OAUTH_ISSUER=https://localhost:8080
export D_OAUTH_ENVIRONMENT=development
export D_OAUTH_BIND_ADDR=0.0.0.0:8080
export D_OAUTH_HEALTH_ADDR=0.0.0.0:8081
export D_OAUTH_TLS__CERT_PATH=/path/to/tls.crt
export D_OAUTH_TLS__KEY_PATH=/path/to/tls.key
export D_OAUTH_DATABASE__URL='postgres://oauth:password@127.0.0.1:5432/oauth?sslmode=disable'
export D_OAUTH_REDIS__URL=redis://127.0.0.1:6379
export D_OAUTH_REDIS__DURABILITY=waitaof
export D_OAUTH_TOKEN_PEPPER='replace-with-32-bytes-of-random-00'
export D_OAUTH_TOTP_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
export D_OAUTH_PII_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'

Development relaxes the database TLS requirement. Production requires sslmode=verify-full (unless database.require_verify_full is set false) and an issuer on https (no trailing slash, non-empty authority, no whitespace).

Validate the configuration, then apply the schema once:

cargo run --bin d-oauth-server -- config check
cargo run --bin d-oauth-server -- migrate

Bootstrap the first administrator. It is created in a forced-change state and must change its password and enroll TOTP before it gains authority:

cargo run --bin d-oauth-server -- admin bootstrap --email admin@example.com

Apply the scope catalog, redirect URIs, and entitlements from a JSON file idempotently. The file format is in docs/OPERATIONS.md:

cargo run --bin d-oauth-server -- config sync --file ./oauth-config.json

Run the server. It serves TLS on bind_addr and plaintext health on health_addr; it never migrates:

cargo run --bin d-oauth-server -- serve

Check it is alive and ready:

curl -s http://127.0.0.1:8081/health/live
curl -s http://127.0.0.1:8081/health/ready
curl -sk https://localhost:8080/.well-known/oauth-authorization-server | jq .

Authorization-code and PKCE walkthrough

The flow is: start at /authorize, log in and consent, receive a code on the redirect, then redeem it at /token with the PKCE verifier.

  1. Generate a verifier and its S256 challenge:

    VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-64)
    CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
    
  2. Visit the authorization endpoint in a browser. A confidential client must use PAR or a signed request object; a public client may use the query string:

    https://localhost:8080/authorize?response_type=code&client_id=example-client&redirect_uri=https://app.example.com/callback&scope=orders:read&state=xyz&code_challenge=$CHALLENGE&code_challenge_method=S256
    
  3. Log in, approve consent, and read the code from the redirect.

  4. Redeem the code at the token endpoint:

    curl -sk https://localhost:8080/token \
    -d grant_type=authorization_code \
    -d code="$CODE" \
    -d redirect_uri=https://app.example.com/callback \
    -d client_id=example-client \
    -d code_verifier="$VERIFIER"
    
  5. A public client must present a DPoP proof with the token request; a confidential client authenticates with its secret or an mTLS certificate.

The POST /token dispatcher and the /register* DCR routes (RFC 7591/RFC 7592) are wired, so step 4 redeems a code end to end; a client can also be created through POST /register. See docs/OAUTH.md for the full grant behavior.

Tests

The authoritative suite. The integration and end-to-end tests connect to the shared test Postgres and Redis from compose.test.yaml (test defaults: Postgres on 127.0.0.1:5433, Redis on 127.0.0.1:6380 — not the dev 5432/6379), so start the services first and tear them down with down -v:

docker compose -f compose.test.yaml up -d
cargo nextest run --all-targets
cargo test --doc
docker compose -f compose.test.yaml down -v

Override the endpoints with D_OAUTH_TEST_POSTGRES_URL and D_OAUTH_TEST_REDIS_URL. Each test gets its own freshly created database and unique Redis key prefixes, so tests run in parallel and nothing flushes shared state. Killing the suite mid-run no longer orphans containers: the lifecycle tests still rm theirs on drop.

Only these suites use testcontainers; every other unit and integration suite uses the shared compose.test.yaml services:

  • src/test_guard.rs and src/state/test_support.rs: the shared container helpers the keep-list below is built on.
  • tests/resilience.rs: stop and restart lifecycle.
  • src/state/tests_topology.rs: Sentinel, Cluster, and requirepass topology.
  • The --appendfsync always refresh-token suite: src/protocol/token/grants/refresh_token/tests.rs and src/protocol/token/grants/refresh_token/tests/failover.rs.
  • The two Redis durability tests in src/store/tests_store.rs (store_persistence_survives_restart and store_durability_barrier_modes).
  • dcr_manage_token_survives_redis_flush in src/protocol/dcr/manage/tests_delete.rs, which starts a private Redis for the flush.

tests/topology.rs is separate: it spawns in-process redis-test Sentinel/Cluster fixtures that need a real redis-server binary. Docker must be running. To iterate on one area, filter by name:

cargo nextest run -E 'test(refresh_rotation)'
cargo nextest run -E 'test(admin_bootstrap)'

Quality gates:

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo nextest run --all-targets
cargo test --doc
cargo audit
cargo deny check
python3 scripts/check_doc_links.py

Container

Build both architectures with the bake definition, then run the image. The image is non-root, has no shell, and expects a read-only root filesystem:

# Both architectures (linux/amd64 + linux/arm64)
docker buildx bake image

# Single architecture (no arm64 QEMU emulation needed)
docker buildx bake image --set image.platform=linux/amd64

docker run --rm -p 8080:8080 --read-only \
  --env-file ./oauth.env \
  -v /path/to/tls:/certs:ro \
  d-oauth-server:latest

Building linux/arm64 on an amd64 host needs QEMU binfmt (docker run --privileged --rm tonistiigi/binfmt --install arm64); see docs/DEPLOYMENT.md.

The container HEALTHCHECK uses the healthcheck subcommand, which probes the plaintext health listener without a shell or curl. See docs/DEPLOYMENT.md.

For a local multi-replica proof, compose.yaml runs three app replicas against one Postgres and one Redis. It is the local analogue of the cluster manifests, and proves that a token issued by one replica is introspectable via another; see docs/DEPLOYMENT.md:

docker compose -f compose.yaml up --scale app=3

Cluster deployment uses a separate migration Job, a PodDisruptionBudget, and probes on the health listener. In-process mTLS requires L4/TCP passthrough at the load balancer, because an L7 ingress that terminates TLS strips the client certificate.

Security caveats

  • OAuth 2.1 only. Do not point an OAuth 2.0 client that needs implicit or password grant at this server.
  • The server targets draft-ietf-oauth-v2-1-16 (published 3 September 2026; an Internet-Draft, not an RFC): https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1. There are no open gaps against that draft; see docs/OAUTH.md.
  • There is no default credential and no HTTP admin API. Provision through the CLI.
  • The token pepper, TOTP encryption key, and PII field-encryption key are required; losing the pepper invalidates every live opaque token, and losing the PII key makes the sealed directory unreadable.
  • Master keys (pepper, TOTP, and PII) must each be at least 32 bytes; shorter values are refused at load (WeakKeyMaterial).
  • user disable purges the account's Redis sessions, tokens, grants, codes, device records, and lockout counters before flipping users.disabled; that user's live tokens then introspect inactive and refuse refresh (invalid_grant).
  • Production requires issuer on https and, unless database.require_verify_full is set false, a database URL with sslmode=verify-full.
  • Redis must run with noeviction and persistence. The server asserts this at startup and refuses to start otherwise, unless redis.noeviction_required or redis.aof_required is set false (both default true).
  • When Postgres or Redis is unavailable, security endpoints return 503 rather than failing open.
  • CORS is empty by default. Browser clients need an explicit origin allowlist.
  • Never commit a secret, a token, or key material. See docs/SECURITY.md.

CLI pointers

  • Scope catalog: scope create|list|enable|disable|delete. Scopes are runtime-managed and start empty.
  • Redirect URIs: redirect-uri create|list|enable|disable|delete and client attach-redirect-uri|detach-redirect-uri|redirect-uris. redirect-uri create|enable|disable|delete and client attach-redirect-uri|detach-redirect-uri accept --kind authorize|post_logout (default authorize); post_logout URIs are the return targets for POST /logout (GET /logout is read-only).
  • Users and entitlements: user create|enroll-totp|disable|unlock|grant-scope|revoke-scope|scopes.
  • Admin: admin bootstrap (see RUNBOOK.md).
  • Declarative apply: config sync --file and config check.
  • PII migration and rotation: pii backfill, pii verify, and pii rotate, plus totp rewrap for the TOTP key. See docs/OPERATIONS.md.
  • DCR initial access: initial-access-token issue --uses <n> --ttl <seconds> [--label <text>] [--show-secret].
  • Utilities: keygen and healthcheck.

Full flags are in docs/OPERATIONS.md.

License

ISC. See LICENSE.md.