Guidance for AI assistants working in this repository. Read this first; the
guides under docs/ carry the depth.
An OAuth 2.1-only authorization server in Rust. Durable domain state is in
PostgreSQL; all ephemeral state is in Redis. The crate is one library
(src/lib.rs) plus a thin binary (src/main.rs). No HTTP admin API; the CLI is
the admin surface.
| Add a... | To this module | Notes |
|---|---|---|
| Configuration key or validation rule | src/config.rs, src/config/ |
Add to the raw serde struct, then validate in the matching submodule. Update docs/CONFIGURATION.md. |
| Key-ring behavior | src/keys.rs |
Versioned ids only; never print key material. |
| Crypto primitive (hash, compare, PKCE, TOTP) | src/crypto/ |
Constant-time for secret compares. |
| PII field codec / blind index | src/crypto/field.rs |
Versioned AES-256-GCM; never log plaintext or key material. |
JOSE verification (DPoP, JAR, private_key_jwt) |
src/jose/ |
Reject fail-closed with the exact error code. |
| Client ID metadata document (CIMD) fetch, validation, or cache | src/protocol/cimd.rs |
One guarded https fetch (no redirects, byte and time caps); only none/private_key_jwt, inline jwks only, no wildcard redirects; cache under {state}. resolve_client is the seam: a registered row wins, else the document when cimd_enabled. Update docs/OAUTH.md, docs/SECURITY.md, and docs/OPERATIONS.md. |
| Postgres query or repository | src/db/ |
query!/query_as! macros; regenerate .sqlx. |
| Resource-server (RFC 8707) catalog query | src/db/resource_repo.rs |
Exact-match only: no wildcard, prefix, or normalization. Update docs/DATABASE.md and docs/OAUTH.md. |
Repeated resource parsing or audience binding |
src/protocol/params.rs |
One wire-order capture for the five raw-parameter endpoints; validate fail-closed with invalid_target. Update docs/OAUTH.md. |
| Redis record or Lua script | src/store/ |
Domain keys under {state}, rate-limit counters under {rl} (must differ); update docs/REDIS.md. |
| OAuth endpoint or grant | src/protocol/ |
Cite the RFC section in a comment. Update docs/OAUTH.md. A subject-scoped Redis purge lives at src/protocol/purge.rs (purge_subject for user disable, purge_sibling_sessions on credential change); domain keys under {state}, rate-limit counters under {rl} and update docs/REDIS.md. |
| Login, consent, or device page | src/ui/ |
Keep CSP and CSRF intact. |
| Cross-cutting HTTP concern | src/middleware/ |
Mind the layer order in src/routes.rs. |
| Endpoint or layer order | src/routes.rs |
Update docs/ARCHITECTURE.md. |
| Listener or shutdown behavior | src/serve.rs, src/tls.rs |
Update docs/DEPLOYMENT.md. |
| Health or topology behavior | src/state/ |
Readiness is fail-closed. |
| CLI command or flag | src/cli.rs, src/cli/ |
Match the flag exactly in docs/OPERATIONS.md. The RFC 8707 catalog lives in src/cli/resource.rs (`resource-server create |
| Error code or HTTP mapping | src/error.rs |
Add the variant and its status together. |
If a change spans modules, put the logic in the module that owns the behavior and call into it from the others.
rustfmt.toml).#![forbid(unsafe_code)] is on the crate. Never add unsafe.unwrap, expect, or panic in request paths. Clippy denies them.thiserror. Map a dependency failure to the right
client-safe error, do not stringify it.tokio and axum idioms: async handlers, tower layers, no blocking
work on the runtime thread.secrecy and zeroize. A secret-holding type must not print the
secret in Debug.subtle) for any secret or token compare.main.rs.StdExternalCrate.Before finishing any change, confirm:
503, never a
fail-open success and never a 500 or panic.private_key_jwt jti replay markers are written with SET NX.S256 only; plain is rejected; a missing verifier cannot
downgrade.private_key_jwt is treated as authentication only, never as a binding.WeakKeyMaterial).invalid_grant before minting, and refresh re-checks current entitlements; user disable purges the subject's Redis state (sessions and their meta, codes, devices with their sidecars and user_code mappings, grants and their token sidecars, standalone tokens and their sidecars, lockout) before flipping users.disabled, failing closed on a Redis error.invalid_target; refresh stays live, device code is already consumed), authorization-code redemption validates a presented audience before consuming the code, and /token, /introspect, and /device_authorization authenticate before the catalog lookup.Red, green, refactor. Write the failing test first and confirm it fails for the right reason.
cargo nextest run -E 'test(<behavior_name>)' # red, then green
Integration tests use the real shared Postgres and Redis from
compose.test.yaml (docker compose -f compose.test.yaml up -d: Postgres on
127.0.0.1:5433, Redis on 127.0.0.1:6380; override with
D_OAUTH_TEST_POSTGRES_URL and D_OAUTH_TEST_REDIS_URL). They get per-test
isolation from a fresh database and unique Redis key prefixes. testcontainers
remains only for the suites that need Redis nodes of their own:
src/test_guard.rs and src/state/test_support.rs (the shared container
helpers), tests/resilience.rs (stop/start lifecycle),
src/state/tests_topology.rs (Sentinel/Cluster/requirepass), the
--appendfsync always refresh-token suite
(src/protocol/token/grants/refresh_token/tests.rs and its tests/failover.rs),
the two Redis durability tests in src/store/tests_store.rs, and
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 uses in-process redis-test
fixtures that need a redis-server binary. Prefer a real object or an in-memory
fake over a mock. Assert the observable contract. Tear the services down with
docker compose -f compose.test.yaml down -v.
Quality gates for any change:
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
Coverage is a separate blocking gate. It runs every target under
cargo llvm-cov against the shared services and checks the global (89.0),
security (91.0), and per-module floors in scripts/coverage_floors.json:
cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
The fuzz/ standalone workspace has 14 targets, and no gate above compiles
it, so a fuzz target that builds a library type by hand goes stale the moment
that struct gains a field. When a change adds or reshapes a type or function a
target constructs or calls, build the targets too:
cd fuzz && cargo fuzz build --target x86_64-unknown-linux-gnu
When a query or migration is added or changed, regenerate and commit the
offline cache. CI runs cargo sqlx prepare --check --workspace and fails if
the committed cache is stale; regenerate it with the plain form:
cargo sqlx prepare --check --workspace # CI gate
cargo sqlx prepare --workspace # regenerate the cache
cargo nextest run -E 'test(admin_bootstrap)'.
Never the full suite, and never repo-wide setup.cargo nextest run --all-targets followed by cargo test --doc.Documentation is part of the change, not a follow-up. 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, docs/RUNBOOK.md.docs/QUICKSTART.md.docs/README.md
index and the table in the root README.md.A change to the deployment surface (a new listener, mount, dependency class, or
runtime setting) updates the attack-surface section in docs/SECURITY.md.
A command in a doc must match the real CLI. Then run the link check:
python3 scripts/check_doc_links.py
The .github/workflows/docs.yml link-check job (mirrored by the doc-links
job in .github/workflows/ci.yml) runs the same check, so a stale file or
heading reference fails the build.