OAuth 2.1 server implementation in Rust

Efren Yevale Varela 35345f1021 Fixed bugs před 2 týdny
.cargo bbab988ffd Updated fuzz, miri, and mutant workflows před 3 týdny
.clusterfuzzlite 923681da88 Changed memory-safety for fuzz před 2 týdny
.config 7f3db91ac6 Hardened test před 3 týdny
.github 96502df740 Changed docker registry behavior před 3 týdny
.sqlx 35345f1021 Fixed bugs před 2 týdny
.well-known 3e8f4d32d1 Security hardening před 3 týdny
deploy 232be93493 Removed pin-digest před 3 týdny
docs 35345f1021 Fixed bugs před 2 týdny
fuzz 9478660260 Added optionals před 2 týdny
migrations 9478660260 Added optionals před 2 týdny
scripts 232be93493 Removed pin-digest před 3 týdny
src 35345f1021 Fixed bugs před 2 týdny
templates 7c50a23515 Initial commit před 1 měsícem
tests 35345f1021 Fixed bugs před 2 týdny
.codegraph 7c50a23515 Initial commit před 1 měsícem
.dockerignore 81769a4f70 Change cache handling před 3 týdny
.gitignore 81769a4f70 Change cache handling před 3 týdny
AGENTS.md 9478660260 Added optionals před 2 týdny
Cargo.lock 9478660260 Added optionals před 2 týdny
Cargo.toml 35345f1021 Fixed bugs před 2 týdny
Dockerfile 3ad38f73cc Updated components před 3 týdny
LICENSE.md 7c50a23515 Initial commit před 1 měsícem
README.md 35345f1021 Fixed bugs před 2 týdny
RUNBOOK.md 9478660260 Added optionals před 2 týdny
SECURITY.md 3e8f4d32d1 Security hardening před 3 týdny
compose.test.yaml 0d522a39e8 Migrated unit tests from per-test containers to shared test services před 3 týdny
compose.yaml 35d12e87ee Updated database versions před 3 týdny
deny.toml 9478660260 Added optionals před 2 týdny
docker-bake.hcl 4197d93e0b Fixed image publishing před 3 týdny
rust-toolchain.toml 7c50a23515 Initial commit před 1 měsícem
rustfmt.toml 7c50a23515 Initial commit před 1 měsícem

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 (with the RFC 8252 §7.3 loopback exception, which includes the host localhost only when allow_localhost_redirect is set).

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 by default; a global enable switch plus a per-client jwt access-token format mints RFC 9068 at+jwt tokens verified against the AS JWKS. 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 DPoP-bound public client (dpop_bound_access_tokens: true) must present a DPoP proof with the token request; an unbound public client (the default) mints Bearer tokens with no proof. A confidential client authenticates with its secret or an mTLS certificate.

Rollout note: unbound public clients become bearer-capable on upgrade. Operators wanting DPoP-mandatory public clients register them with dpop_bound_access_tokens: true.

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).
  • Resource indicators: resource-server create|list|enable|disable|delete manage the RFC 8707 resource-server catalog (exact identifiers, no wildcard).
  • Access-token format: client set-access-token-format --client-id <id> --format opaque|jwt is the operator-only opt-in to JWT access tokens; dynamic registration always stores opaque.
  • 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.