How to build, test, and change the d-OAuth-Server authorization server. The rules here are enforced by CI, so a change that skips them will not merge.
This guide is for anyone editing the Rust source. It covers prerequisites, the quality gates, module conventions, the test-driven workflow, commit style, and the documentation contract. Operators looking to run the server should start with the README and OPERATIONS.md.
rust-toolchain.toml (stable, with rustfmt
and clippy). rustup installs it automatically on first build.cargo-nextest for the test suite:
cargo install cargo-nextest --locked.compose.test.yaml (docker compose -f compose.test.yaml up -d), and for
the handful of suites that still use testcontainers (see
Database-backed tests).sqlx-cli only when you add or change a SQL query or a migration:
cargo install sqlx-cli --locked --features runtime-tokio,tls-rustls-ring.The crate uses edition 2024 and the rustfmt settings in rustfmt.toml
(2-space indent, StdExternalCrate import grouping).
cargo build --locked # debug
cargo build --locked --release # release (thin LTO, stripped)
The build is offline-safe for SQL: with SQLX_OFFLINE=true the query! macros
compile against the committed .sqlx/ cache, so no database is needed at build
time. The Docker build relies on exactly this. When a query or a migration
changes, regenerate and commit the cache. CI runs the check form and fails if
the committed cache is stale:
cargo sqlx prepare --check --workspace # CI gate
cargo sqlx prepare --workspace # regenerate the cache
Run all of these before opening a change. CI runs this set plus additional
gates: cargo sqlx prepare --check --workspace, the fuzz targets
(cargo fuzz build), coverage, cargo mutants, miri, and the separate
.github/workflows/docs.yml link check.
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
Start the shared test services before cargo nextest run --all-targets:
docker compose -f compose.test.yaml up -d, then tear them down with
docker compose -f compose.test.yaml down -v. cargo nextest run is the
authoritative test entry point. It runs unit, integration, and end-to-end tests;
they all use the shared services except the keep-list under
Database-backed tests, which needs testcontainers.
cargo test --doc runs the doc examples separately because nextest does not
cover doctests.
While iterating on one area, filter to just that area:
cargo nextest run -E 'test(refresh_rotation)'
cargo nextest run -E 'test(admin_bootstrap)'
A delegated or subagent change runs only the narrowest filter that covers the touched code. The full suite runs once, at integration time.
Integration tests connect to the shared Postgres and Redis started from
compose.test.yaml:
docker compose -f compose.test.yaml up -d
cargo nextest run --all-targets
docker compose -f compose.test.yaml down -v
The defaults are Postgres on 127.0.0.1:5433 and Redis on 127.0.0.1:6380,
overridable through D_OAUTH_TEST_POSTGRES_URL and D_OAUTH_TEST_REDIS_URL.
Each test isolates itself with a freshly created database and unique Redis key
prefixes, so parallel tests never share state and nothing is flushed.
Migrations are never run by the test process against the shared postgres
database; a test that asserts on a migration applies it to its own fresh
database.
The suites that need their own Redis nodes are the exception. Only these use
testcontainers:
src/test_guard.rs and src/state/test_support.rs: the shared container
helpers the rest of this list is built on.tests/resilience.rs: stops and restarts containers; the guards synchronously
rm them on drop, so killing the suite mid-run no longer orphans containers.src/state/tests_topology.rs: Sentinel, Cluster, and requirepass topology.--appendfsync always refresh-token suite:
src/protocol/token/grants/refresh_token/tests.rs and its tests/failover.rs.src/store/tests_store.rs.dcr_manage_token_survives_redis_flush in
src/protocol/dcr/manage/tests_delete.rs.Every other unit and integration suite uses the shared services.
tests/topology.rs is separate: it spawns in-process redis-test
Sentinel/Cluster fixtures, which need a real redis-server binary. Docker must
be running, and the in-process topology fixtures additionally need that binary.
Fuzz targets live in fuzz/fuzz_targets/ (a standalone workspace under
fuzz/, built only by the nightly leg — the root stable pin is untouched):
| Target | Real entry point fuzzed |
|---|---|
token_form |
crypto::tokens::TokenHash::parse_storage_string |
dcr_json |
protocol::dcr::RegisterRequest deserialization |
jar_claims |
JWT-claims envelope shape + jose::jar::require_par_or_jar |
dpop_proof |
jose::jwk::{decoding_key, thumbprint} (embedded-JWK gate) |
jar_verify |
protocol::client_auth::assertion::{unverified_iss, validate_assertion} |
redirect_uri |
protocol::redirect_repo::match_redirect_uri |
scope_parse |
protocol::scope::ScopeSet::{parse, render} |
Every target is small and hermetic (no network, Postgres, or Redis):
signature verification that needs keys and replay claims that need the store
stay under unit/proptest cover. Targets contain no unwrap/expect/panic/
unsafe, so arbitrary input can only surface a genuine parser panic.
cd fuzz
cargo fuzz build # every target must compile
cargo fuzz run <target> -- -max_total_time=60 -rss_limit_mb=2048
The containerized (ClusterFuzzLite/OSS-Fuzz) build runs inside the Rust base
image, which prepends -fsanitize=address … to CFLAGS/CXXFLAGS and exports
CXX=clang++ with a CXXFLAGS selecting libc++. .clusterfuzzlite/build.sh
drops CFLAGS/CXXFLAGS/RUSTFLAGS for the build:
cc-compiled C/asm in a dependency
(ring, pulled by sqlx's tls-rustls-ring-webpki feature). Those objects
reach the host proc-macro sqlx-macros through its dependency graph, and
rustc does not link the ASan runtime into a proc-macro cdylib, so that .so
fails at dlopen with undefined __asan_*/__sancov_lowest_stack.CXXFLAGS also removes -stdlib=libc++; libfuzzer-sys compiles
libFuzzer's C++ runtime with $CXX while rustc/cc links -lstdc++, so the
runtime's libc++ (std::__1::) symbols would otherwise go undefined. clang++
then defaults to libstdc++.The Rust code keeps full ASan + libFuzzer coverage: cargo fuzz supplies those
Rust flags itself (the same scoped flags as the ci.yml fuzz-build leg, which
runs with no sanitizer env). The image pins cargo-fuzz so the tool cannot
drift.
Corpora persist across runs: cargo fuzz run reads and writes
fuzz/corpus/<target>/ (gitignored, but cached across CI runs by the
fuzz-run leg), and crashes land in fuzz/artifacts/<target>/. To grow a
local seed, drop an input in fuzz/corpus/<target>/ and confirm the
target still passes; keep seeds tiny and non-secret (never a real token, key,
or password). Seeds stay local: fuzz/.gitignore excludes corpus/, so a
seed is only shared by committing it with git add -f deliberately.
Continuous coverage is ClusterFuzzLite (.clusterfuzzlite/ plus
.github/workflows/cflite_pr.yml for PRs and cflite_batch.yml for the
daily batch run). One-time owner setup for Efren:
cflite_*.yml workflows (PR fuzzing needs only the default
GITHUB_TOKEN)..clusterfuzzlite/build.sh
already builds every target.Dockerfile and
build.sh layout.The crate is one library (src/lib.rs) plus a thin binary (src/main.rs).
Every module has a single responsibility:
| Area | Path | Owns |
|---|---|---|
| Configuration | src/config.rs, src/config/ |
Layered env/file load and fail-fast validation. |
| Keys | src/keys.rs |
Versioned KeyRing for the token pepper, TOTP encryption key, and PII field-encryption key. |
| Crypto primitives | src/crypto/ |
Constant-time compare, PKCE, token mint/hash, TOTP, secret hashing. |
| Field encryption | src/crypto/field.rs |
AES-256-GCM PII field codec and keyed-HMAC blind index. |
| JOSE | src/jose/ |
DPoP, JAR, private_key_jwt verification, JWK parsing. |
| Postgres repositories | src/db/ |
The seven domain tables. |
| Redis store | src/store/ |
Ephemeral state: codes, tokens, grants, sessions, PAR, device, DCR, rate limits. |
| Protocol handlers | src/protocol/ |
OAuth endpoints and grants. |
| Server-rendered UI | src/ui/ |
Login, consent, and device pages. |
| Middleware | src/middleware/ |
Request id, tracing/redaction, rate limit, security headers, audit. |
| Resilience | src/resilience.rs |
Load-shed, timeout, and concurrency-limit guard. |
| Routing and serving | src/routes.rs, src/serve.rs, src/tls.rs |
Router assembly and the two listeners. |
| Application state | src/state.rs, src/state/ |
AppState, the topology-aware Redis connection, health endpoints (health/pg/redis/sentinel). |
| Operator CLI | src/cli.rs, src/cli/ |
Every administrative command. |
| Errors | src/error.rs |
AppError: the client-safe OAuth error type and its HTTP mapping. |
Rules that apply everywhere:
#![forbid(unsafe_code)] is on the crate.unwrap, expect, or panic in request paths. They are denied by the
clippy lints in Cargo.toml.thiserror; map dependency failures to the correct
client-safe error rather than stringifying them.secrecy and zeroize. A type that holds a secret must not have
a Debug impl that prints it.subtle) for any secret or token comparison.main.rs free of business logic.Every behavior change follows red, green, refactor:
Write the test against the shared Postgres and Redis from
compose.test.yaml (defaults 127.0.0.1:5433 and 127.0.0.1:6380, override
with D_OAUTH_TEST_POSTGRES_URL / D_OAUTH_TEST_REDIS_URL), with a fresh
database and unique Redis prefixes per test. Reach for testcontainers only
when the test needs Redis nodes of its own (the keep-list under
Database-backed tests); stop/start lifecycle cases are
the common reason. Prefer a real object or an in-memory fake over a mock. Assert
the observable contract, not an implementation dump.
One commit per logical change, in conventional-commit form:
feat(token): add token-exchange grant
fix(store): fail closed when the durability barrier is missed
docs(agents): document the module map
test(resilience): cover Redis restart recovery
The subject is imperative and lowercase after the type. The body explains why, and links the evidence file when the task process requires one. Never commit a secret, a real token, or key material.
The docs are part of the code, not an afterthought. Any behavior change updates the relevant guide in the same commit:
docs/OAUTH.md.docs/SECURITY.md.docs/ARCHITECTURE.md.docs/CONFIGURATION.md.docs/DATABASE.md.docs/REDIS.md.docs/DEPLOYMENT.md.docs/OPERATIONS.md.Then run the link checker, which CI also runs:
python3 scripts/check_doc_links.py
A command in a doc must match the real CLI. If you change a flag, change the doc in the same commit.