Efren Yevale Varela 3 недель назад
Родитель
Сommit
a95ffc0220
12 измененных файлов с 154 добавлено и 40 удалено
  1. 5 3
      AGENTS.md
  2. 6 0
      README.md
  3. 1 1
      RUNBOOK.md
  4. 7 2
      docs/ARCHITECTURE.md
  5. 19 0
      docs/CHANGELOG.md
  6. 7 1
      docs/CONTRIBUTING.md
  7. 4 1
      docs/DATABASE.md
  8. 20 12
      docs/DEPLOYMENT.md
  9. 31 8
      docs/OAUTH.md
  10. 13 3
      docs/REDIS.md
  11. 39 8
      docs/SECURITY.md
  12. 2 1
      src/routes.rs

+ 5 - 3
AGENTS.md

@@ -21,7 +21,7 @@ the admin surface.
 | JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
 | JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
 | Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
 | Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
 | Redis record or Lua script | `src/store/` | Keep every key under `{state}`; update [docs/REDIS.md](docs/REDIS.md). |
 | Redis record or Lua script | `src/store/` | Keep every key under `{state}`; update [docs/REDIS.md](docs/REDIS.md). |
-| OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). |
+| OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). A subject-scoped Redis purge lives at `src/protocol/purge.rs` (`purge_subject`), used by `user disable`; keep every key under `{state}` and update docs/REDIS.md. |
 | Login, consent, or device page | `src/ui/` | Keep CSP and CSRF intact. |
 | 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`. |
 | 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](docs/ARCHITECTURE.md). |
 | Endpoint or layer order | `src/routes.rs` | Update [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). |
@@ -67,6 +67,8 @@ Before finishing any change, confirm:
 - [ ] Only hashed or encrypted secrets are stored; no raw token at rest.
 - [ ] Only hashed or encrypted secrets are stored; no raw token at rest.
 - [ ] A sender-constrained grant re-verifies a fresh proof on every use.
 - [ ] A sender-constrained grant re-verifies a fresh proof on every use.
 - [ ] `private_key_jwt` is treated as authentication only, never as a binding.
 - [ ] `private_key_jwt` is treated as authentication only, never as a binding.
+- [ ] Master keys (pepper, TOTP/PII keys, and retained previous keys) carry at least 32 bytes; shorter material is rejected at load (`WeakKeyMaterial`).
+- [ ] A disabled subject is dead everywhere: introspection reports inactive, refresh rejects with `invalid_grant` before minting, and refresh re-checks current entitlements; `user disable` purges the subject's Redis state (sessions, codes, devices, grants, standalone tokens, lockout) before flipping `users.disabled`, failing closed on a Redis error.
 - [ ] The change does not introduce a wildcard scope, redirect URI, or CORS
 - [ ] The change does not introduce a wildcard scope, redirect URI, or CORS
       origin.
       origin.
 
 
@@ -125,8 +127,8 @@ updates the relevant guide in the same commit:
   `docs/SECURITY.md`.
   `docs/SECURITY.md`.
 - Modules, lifecycle, topology: `docs/ARCHITECTURE.md`.
 - Modules, lifecycle, topology: `docs/ARCHITECTURE.md`.
 - Config keys and defaults: `docs/CONFIGURATION.md`.
 - Config keys and defaults: `docs/CONFIGURATION.md`.
-- Schema and migrations: `docs/DATABASE.md`.
-- Redis keys, TTLs, durability: `docs/REDIS.md`.
+- Schema and migrations, disabled-user re-check: `docs/DATABASE.md`.
+- Redis keys, TTLs, durability, subject purge: `docs/REDIS.md`.
 - Image, manifests, probes: `docs/DEPLOYMENT.md`.
 - Image, manifests, probes: `docs/DEPLOYMENT.md`.
 - CLI and runbooks: `docs/OPERATIONS.md`, `RUNBOOK.md`.
 - CLI and runbooks: `docs/OPERATIONS.md`, `RUNBOOK.md`.
 - This file, when the module map or rules change.
 - This file, when the module map or rules change.

+ 6 - 0
README.md

@@ -236,6 +236,12 @@ certificate.
 - The token pepper, TOTP encryption key, and PII field-encryption key are
 - The token pepper, TOTP encryption key, and PII field-encryption key are
   required; losing the pepper invalidates every live opaque token, and losing
   required; losing the pepper invalidates every live opaque token, and losing
   the PII key makes the sealed directory unreadable.
   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 counter before flipping `users.disabled`; that
+  user's live tokens then introspect inactive and refuse refresh
+  (`invalid_grant`).
 - Production requires `issuer` on `https` and a database URL with
 - Production requires `issuer` on `https` and a database URL with
   `sslmode=verify-full`.
   `sslmode=verify-full`.
 - Redis must run with `noeviction` and persistence. The server asserts this at
 - Redis must run with `noeviction` and persistence. The server asserts this at

+ 1 - 1
RUNBOOK.md

@@ -85,7 +85,7 @@ admin-gated actions deny it until both steps complete.
 - [ ] Create a named second admin:
 - [ ] Create a named second admin:
       `d-oauth-server user create --admin --email <name>@example.com`
       `d-oauth-server user create --admin --email <name>@example.com`
 - [ ] Log in as the named admin and verify full access.
 - [ ] Log in as the named admin and verify full access.
-- [ ] Disable the bootstrap account: `d-oauth-server user disable --email ops@example.com`
+- [ ] Disable the bootstrap account: `d-oauth-server user disable --email ops@example.com`. `user disable` purges the account's Redis sessions, tokens, grants, codes, device records, and lockout counter before flipping the Postgres `disabled` flag, and fails closed (leaving the user enabled) if Redis is unavailable. Records with no subject (PAR, pending, replay markers, rotation map, TOTP replay, rate-limit counters) are untouched. See docs/OPERATIONS.md.
 - [ ] Rotate/expire the bootstrap secret: delete the mounted
 - [ ] Rotate/expire the bootstrap secret: delete the mounted
       `/run/secrets/bootstrap-password` material and revoke any copy of a
       `/run/secrets/bootstrap-password` material and revoke any copy of a
       generated one-time password.
       generated one-time password.

+ 7 - 2
docs/ARCHITECTURE.md

@@ -27,7 +27,7 @@ the library.
 | `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
 | `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
 | `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
-| `src/protocol/` | OAuth endpoints and grants. See [OAUTH.md](OAUTH.md). |
+| `src/protocol/` | OAuth endpoints and grants, plus the subject-scoped Redis purge (`src/protocol/purge.rs`, `purge_subject`, used by `user disable`). |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/resilience.rs` | Load-shed, concurrency-limit, and timeout guard between CORS and rate limiting. |
 | `src/resilience.rs` | Load-shed, concurrency-limit, and timeout guard between CORS and rate limiting. |
@@ -86,7 +86,12 @@ The split is strict and drives the whole design:
 - PostgreSQL holds durable domain state only: `oauth_clients`, `users`,
 - PostgreSQL holds durable domain state only: `oauth_clients`, `users`,
   `scopes`, `user_scopes`, `redirect_uris`, `client_redirect_uris`, plus sqlx's
   `scopes`, `user_scopes`, `redirect_uris`, `client_redirect_uris`, plus sqlx's
   `_sqlx_migrations`. Nothing is written to Postgres per request. A failed login
   `_sqlx_migrations`. Nothing is written to Postgres per request. A failed login
-  does not touch Postgres; the counter lives in Redis.
+  does not touch Postgres; the counter lives in Redis. Introspection and refresh
+  do re-read Postgres on every use: a non-empty subject resolving to a disabled
+  user yields inactive (introspection) or `invalid_grant` (refresh) without
+  minting, and refresh re-reads the user's current entitlements; an empty or
+  unknown subject stays live. `user disable` purges the user's Redis state
+  before flipping `users.disabled`, failing closed on a Redis error.
 - Redis holds every short-lived value: authorization codes, access and refresh
 - Redis holds every short-lived value: authorization codes, access and refresh
   tokens, grant chains, sessions, PAR request URIs, device codes, DCR
   tokens, grant chains, sessions, PAR request URIs, device codes, DCR
   initial-access tokens, DPoP and `private_key_jwt` replay markers, and
   initial-access tokens, DPoP and `private_key_jwt` replay markers, and

+ 19 - 0
docs/CHANGELOG.md

@@ -60,6 +60,11 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   rejects one), and `automountServiceAccountToken` is `false`. Remote JAR
   rejects one), and `automountServiceAccountToken` is `false`. Remote JAR
   `request_uri` is documented as unsupported under the default-deny, with the
   `request_uri` is documented as unsupported under the default-deny, with the
   Cilium FQDN opt-in path in `docs/DEPLOYMENT.md`.
   Cilium FQDN opt-in path in `docs/DEPLOYMENT.md`.
+- `user disable` now purges all Redis state for the subject (sessions and their
+  meta, codes, devices with their sidecars and mappings, grants via cascade
+  revoke, standalone tokens, and the lockout counter) before flipping
+  `users.disabled`; a Redis failure aborts before the flag changes, so the
+  command fails closed.
 
 
 ### Changed
 ### Changed
 
 
@@ -79,6 +84,10 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   security-header layer) now sets `Cache-Control: no-store` and
   security-header layer) now sets `Cache-Control: no-store` and
   `X-Content-Type-Options: nosniff` on its own probe responses, with a
   `X-Content-Type-Options: nosniff` on its own probe responses, with a
   regression test pinning both.
   regression test pinning both.
+- Forwarded client-cert and IP headers are never trusted; the dead
+  proxy-header fallback is removed.
+- The login lockout now also covers TOTP enrollment confirmation, and the rate
+  limiter classifies requests by their full path.
 
 
 ### Security
 ### Security
 
 
@@ -87,6 +96,16 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   emits a `pii.decrypt_failure` audit event carrying only the request id, never
   emits a `pii.decrypt_failure` audit event carrying only the request id, never
   a value. Full-blob substitution by an attacker with database write access
   a value. Full-blob substitution by an attacker with database write access
   remains out of the threat model and is documented in `docs/SECURITY.md`.
   remains out of the threat model and is documented in `docs/SECURITY.md`.
+- Introspection and refresh re-check `users.disabled`, and refresh re-checks
+  the subject's current entitlements; a disabled subject introspects inactive
+  and refresh rejects with `invalid_grant` before any token is minted.
+- Master keys (the token pepper, TOTP key, PII key, and any retained previous
+  key) shorter than 32 bytes are rejected at load with `WeakKeyMaterial`.
+- `private_key_jwt` assertion lifetime is capped and its `jti` replay marker
+  now tracks the remaining validity; `PS256` is added to the assertion
+  algorithm allowlist.
+- DCR `open` redirect policy now accepts only `https` or RFC 8252 loopback
+  URIs, and the SSRF guard pins the fetch to the addresses it pre-validated.
 
 
 ### Fixed
 ### Fixed
 
 

+ 7 - 1
docs/CONTRIBUTING.md

@@ -34,7 +34,12 @@ cargo build --locked --release  # release (thin LTO, stripped)
 
 
 The build is offline-safe for SQL: with `SQLX_OFFLINE=true` the `query!` macros
 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
 compile against the committed `.sqlx/` cache, so no database is needed at build
-time. The Docker build relies on exactly this.
+time. The Docker build relies on exactly this. When a query or a migration
+changes, regenerate and commit the cache:
+
+```bash
+cargo sqlx prepare --workspace
+```
 
 
 ## Quality gates
 ## Quality gates
 
 
@@ -47,6 +52,7 @@ cargo nextest run --all-targets
 cargo test --doc
 cargo test --doc
 cargo audit
 cargo audit
 cargo deny check
 cargo deny check
+python3 scripts/check_doc_links.py
 ```
 ```
 
 
 Start the shared test services before `cargo nextest run --all-targets`:
 Start the shared test services before `cargo nextest run --all-targets`:

+ 4 - 1
docs/DATABASE.md

@@ -85,7 +85,7 @@ One row per resource owner.
 | `is_admin` | `bool` | Default false. Gates privileged actions. |
 | `is_admin` | `bool` | Default false. Gates privileged actions. |
 | `must_change_password` | `bool` | Default false; true for a bootstrapped admin. |
 | `must_change_password` | `bool` | Default false; true for a bootstrapped admin. |
 | `password_changed_at` | `timestamptz` | Stamped on password change. |
 | `password_changed_at` | `timestamptz` | Stamped on password change. |
-| `disabled` | `bool` | Default false. |
+| `disabled` | `bool` | Default false. Introspection and refresh re-check this flag on every use via the subject account-status lookup: a disabled subject is inactive / `invalid_grant`, while an empty or unknown subject stays live; a datastore failure propagates and never reports active. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 
 `ux_users_email_index` is a partial unique index on `email_index` where it is
 `ux_users_email_index` is a partial unique index on `email_index` where it is
@@ -265,6 +265,9 @@ during a maintenance window with the app tier quiesced.
   Postgres, so RFC 7592 client management survives a Redis loss.
   Postgres, so RFC 7592 client management survives a Redis loss.
 - Foreign keys with `ON DELETE CASCADE` keep the join tables consistent, and a
 - Foreign keys with `ON DELETE CASCADE` keep the join tables consistent, and a
   scope referenced by `user_scopes` is disabled rather than deleted.
   scope referenced by `user_scopes` is disabled rather than deleted.
+- `user disable` purges the subject's Redis state before the
+  `UPDATE users SET disabled` runs; a Redis failure aborts before the flag
+  flips.
 
 
 ## Access hardening
 ## Access hardening
 
 

+ 20 - 12
docs/DEPLOYMENT.md

@@ -264,6 +264,7 @@ loader at them:
 ```text
 ```text
 D_OAUTH_TOKEN_PEPPER_FILE=/run/secrets/token-pepper
 D_OAUTH_TOKEN_PEPPER_FILE=/run/secrets/token-pepper
 D_OAUTH_TOTP_ENCRYPTION_KEY_FILE=/run/secrets/totp-encryption-key
 D_OAUTH_TOTP_ENCRYPTION_KEY_FILE=/run/secrets/totp-encryption-key
+D_OAUTH_PII_ENCRYPTION_KEY_FILE=/run/secrets/pii-encryption-key
 D_OAUTH_DATABASE__PASSWORD_FILE=/run/secrets/database-password
 D_OAUTH_DATABASE__PASSWORD_FILE=/run/secrets/database-password
 D_OAUTH_REDIS__PASSWORD_FILE=/run/secrets/redis-password
 D_OAUTH_REDIS__PASSWORD_FILE=/run/secrets/redis-password
 ```
 ```
@@ -277,20 +278,22 @@ describe` output, or in a crash dump — a `kubectl exec` into the container
 reads nothing useful from the environment. `scripts/check_manifests.py` fails
 reads nothing useful from the environment. `scripts/check_manifests.py` fails
 the build if a workload injects a secret through a `secretKeyRef` env, or if a
 the build if a workload injects a secret through a `secretKeyRef` env, or if a
 ConfigMap DSN embeds a password. The inline `D_OAUTH_TOKEN_PEPPER` /
 ConfigMap DSN embeds a password. The inline `D_OAUTH_TOKEN_PEPPER` /
-`D_OAUTH_TOTP_ENCRYPTION_KEY` forms remain supported and take precedence when
-both are set (`src/config/build.rs`), so an existing environment-based
-deployment still starts; the shipped manifests deliberately never set them.
+`D_OAUTH_TOTP_ENCRYPTION_KEY` / `D_OAUTH_PII_ENCRYPTION_KEY` forms remain
+supported and take precedence when the inline forms are set
+(`src/config/build.rs`), so an existing environment-based deployment still
+starts; the shipped manifests deliberately never set them.
 The migrate Job mounts the same files. To rotate a password, update it in
 The migrate Job mounts the same files. To rotate a password, update it in
 Postgres/Redis and the Secret together, then roll the replicas; see
 Postgres/Redis and the Secret together, then roll the replicas; see
 [CONFIGURATION.md](CONFIGURATION.md) and [OPERATIONS.md](OPERATIONS.md).
 [CONFIGURATION.md](CONFIGURATION.md) and [OPERATIONS.md](OPERATIONS.md).
 
 
 ### Master key custody and KMS/HSM
 ### Master key custody and KMS/HSM
 
 
-The token pepper and the TOTP-encryption key are the server's master keys. The
-binary never fetches a key and carries no cloud KMS or HSM SDK; it reads the
-material from `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`
-(or the inline forms), so custody is entirely the deployment's responsibility.
-Treat this as a hard requirement, not a preference:
+The token pepper, the TOTP-encryption key, and the PII field-encryption key are
+the server's master keys. The binary never fetches a key and carries no cloud
+KMS or HSM SDK; it reads the material from `D_OAUTH_TOKEN_PEPPER_FILE` /
+`D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` / `D_OAUTH_PII_ENCRYPTION_KEY_FILE` (or the
+inline forms), so custody is entirely the deployment's responsibility. Treat
+this as a hard requirement, not a preference:
 
 
 - **Never co-locate the key with the database.** The keys must not live in a
 - **Never co-locate the key with the database.** The keys must not live in a
   Postgres table, in the database backup, or in any archive that also carries a
   Postgres table, in the database backup, or in any archive that also carries a
@@ -309,6 +312,10 @@ Treat this as a hard requirement, not a preference:
   key is a second secret and is custodied under the same rule, apart from the
   key is a second secret and is custodied under the same rule, apart from the
   backup it protects.
   backup it protects.
 
 
+Every resolved key value — pepper, TOTP, PII, active or retained previous —
+must be at least 32 bytes (256 bits); shorter values are refused at load
+(`ConfigError::WeakKeyMaterial`).
+
 Rotation keeps the chain honest: retaining the old pepper key id is what lets
 Rotation keeps the chain honest: retaining the old pepper key id is what lets
 live tokens verify until they expire, and retaining the old TOTP key id is only
 live tokens verify until they expire, and retaining the old TOTP key id is only
 long enough to re-enroll users. The full contract, the forbidden locations, and
 long enough to re-enroll users. The full contract, the forbidden locations, and
@@ -483,9 +490,9 @@ The image pipeline is gated on:
   surface to the binary.
   surface to the binary.
 - Non-root with a read-only root filesystem.
 - Non-root with a read-only root filesystem.
 - Secrets and CA material are mounted, never baked into the image or manifests.
 - Secrets and CA material are mounted, never baked into the image or manifests.
-- Every secret — token pepper, TOTP key, and the database and Redis passwords —
-  is a `0400` tmpfs file, not an environment variable, and the pod carries no
-  service-account token.
+- Every secret — token pepper, TOTP key, PII key, and the database and Redis
+  passwords — is a `0400` tmpfs file, not an environment variable, and the pod
+  carries no service-account token.
 - `kubectl exec` / `docker exec` reach a non-root process with a read-only
 - `kubectl exec` / `docker exec` reach a non-root process with a read-only
   rootfs, no capabilities, and no shell, but still bypass the application's own
   rootfs, no capabilities, and no shell, but still bypass the application's own
   auth; restrict `pods/exec` with RBAC and audit it (see
   auth; restrict `pods/exec` with RBAC and audit it (see
@@ -546,7 +553,8 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   weekly rebuild.
   weekly rebuild.
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
 - `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
   (non-secret settings only, including the passwordless DSNs), `secret.yaml`
   (non-secret settings only, including the passwordless DSNs), `secret.yaml`
-  (placeholder secrets — pepper, TOTP key, database and Redis passwords — all
+  (placeholder secrets — pepper, TOTP key, PII key, database and Redis
+  passwords — all
   file-mounted and replaced by the secret manager), `service.yaml` (public TLS
   file-mounted and replaced by the secret manager), `service.yaml` (public TLS
   port only, never the health port), `deployment.yaml` (3 stateless replicas,
   port only, never the health port), `deployment.yaml` (3 stateless replicas,
   probes on the plaintext health listener, PDB + topology spread,
   probes on the plaintext health listener, PDB + topology spread,

+ 31 - 8
docs/OAUTH.md

@@ -132,6 +132,14 @@ JSON response; an unknown `grant_type` is `unsupported_grant_type`. The DCR
 routes map onto `protocol::dcr::register_handler` and
 routes map onto `protocol::dcr::register_handler` and
 `protocol::dcr::manage::{read,update,delete}_handler` (RFC 7591 / RFC 7592).
 `protocol::dcr::manage::{read,update,delete}_handler` (RFC 7591 / RFC 7592).
 
 
+### Dynamic client registration
+
+Dynamic registration redirect policy (`dcr_redirect_policy`): `catalog` attaches
+only catalog-registered URIs; `catalog_loopback` additionally upserts RFC 8252
+loopback URIs; `open` additionally upserts any absolute `https` URI with a
+non-empty host and no userinfo, fragment, or whitespace. Loopback URIs are
+always allowed. Anything else is `invalid_redirect_uri`.
+
 ## The five grants
 ## The five grants
 
 
 The advertised set is `authorization_code`, `refresh_token`,
 The advertised set is `authorization_code`, `refresh_token`,
@@ -159,14 +167,17 @@ Reference: RFC 6749 §6, RFC 9700 §2.2.2 and §4.14, RFC 9449 §5.
 
 
 Parameters: `client_id`, `refresh_token`, optional `requested_scope` (which may
 Parameters: `client_id`, `refresh_token`, optional `requested_scope` (which may
 narrow but never widen), and a freshly verified sender proof. The effective
 narrow but never widen), and a freshly verified sender proof. The effective
-scope is re-checked against the client's current registration and the user's
-current entitlements, so a revoked scope or entitlement drops on refresh.
+scope is re-checked against the current `enabled` catalog and the client's
+current registration and the user's current entitlements (fresh Postgres
+reads), so a revoked scope or entitlement drops on refresh.
 
 
 Behavior: rotation on every use. The presented token is tombstoned with a
 Behavior: rotation on every use. The presented token is tombstoned with a
 `rotated_to` member. Presenting a rotated token again is reuse: the whole grant
 `rotated_to` member. Presenting a rotated token again is reuse: the whole grant
 family is revoked and the request fails with `invalid_grant`. A lost reply does
 family is revoked and the request fails with `invalid_grant`. A lost reply does
 not double-rotate, because the rotation is keyed by a deterministic request id
 not double-rotate, because the rotation is keyed by a deterministic request id
-and a retry returns the same tokens.
+and a retry returns the same tokens. If the refresh token's non-empty subject
+resolves to a disabled user, the request fails `invalid_grant` before minting or
+rotating.
 
 
 ### client_credentials
 ### client_credentials
 
 
@@ -286,7 +297,10 @@ on every use.
 `private_key_jwt` is client authentication only. It produces no confirmation and
 `private_key_jwt` is client authentication only. It produces no confirmation and
 never satisfies sender constraint. For token exchange, the proof must match the
 never satisfies sender constraint. For token exchange, the proof must match the
 subject token's confirmation. See [SECURITY.md](SECURITY.md) for the full matrix
 subject token's confirmation. See [SECURITY.md](SECURITY.md) for the full matrix
-and the confirmation propagation rule.
+and the confirmation propagation rule. It accepts `RS256`, `PS256`, `ES256`, and
+`EdDSA` only (`none` and HMAC are rejected); `exp` may lie at most 300 s past
+`now` plus 30 s clock skew, and the single-use `jti` marker lives for the
+assertion's remaining validity, clamped to `[1 s, 360 s]`.
 
 
 ## Metadata
 ## Metadata
 
 
@@ -300,7 +314,9 @@ than advertising an unknown list.
 The document carries no `jwks_uri`: the server signs no artifact of its own.
 The document carries no `jwks_uri`: the server signs no artifact of its own.
 `code_challenge_methods_supported` advertises only `S256`.
 `code_challenge_methods_supported` advertises only `S256`.
 `authorization_response_iss_parameter_supported` is true, so the authorization
 `authorization_response_iss_parameter_supported` is true, so the authorization
-response carries `iss` (RFC 9207).
+response carries `iss` (RFC 9207). The document advertises
+`token_endpoint_auth_signing_alg_values_supported = [ES256, EdDSA, RS256,
+PS256]`; `dpop_signing_alg_values_supported` stays `[ES256, EdDSA, RS256]`.
 
 
 ## JAR and PAR
 ## JAR and PAR
 
 
@@ -314,7 +330,9 @@ Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
   algorithm. Conflicts between the object and the query string are rejected.
   algorithm. Conflicts between the object and the query string are rejected.
 - A `request_uri` is a one-time, client-bound PAR handle or an exactly
 - A `request_uri` is a one-time, client-bound PAR handle or an exactly
   pre-registered URI. It is fetched only with the SSRF guard: https only, no
   pre-registered URI. It is fetched only with the SSRF guard: https only, no
-  redirects, global IPs only, size and time capped.
+  redirects, global IPs only, size and time capped. The connection is pinned to
+  the addresses validated before the fetch, so a rebinding hostname cannot steer
+  it to a blocked address.
 - A remote (pre-registered, non-PAR) `request_uri` is fetched over outbound
 - A remote (pre-registered, non-PAR) `request_uri` is fetched over outbound
   HTTPS, which the shipped cluster network policy default-denies. It is
   HTTPS, which the shipped cluster network policy default-denies. It is
   available only when the deployment adds a scoped egress allowance for the
   available only when the deployment adds a scoped egress allowance for the
@@ -331,8 +349,11 @@ Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
 
 
 ## Edge cases
 ## Edge cases
 
 
-- A refresh request that omits `scope` keeps the prior scope; a request that
-  asks for a scope the client was not granted fails.
+- A refresh request that omits `scope` keeps the prior grant only while still
+  enabled, then narrows to the still-registered ∩ still-entitled intersection
+  (possibly empty, from fresh Postgres reads of the client registration, the
+  user entitlements, and the `enabled` catalog); an explicit `scope` must be a
+  subset of the prior grant and all three current sets.
 - A second authorization code made from the same request is a distinct code;
 - A second authorization code made from the same request is a distinct code;
   each is single-use.
   each is single-use.
 - A DPoP proof outside the `iat` freshness window, or with a replayed `jti`, is
 - A DPoP proof outside the `iat` freshness window, or with a replayed `jti`, is
@@ -350,6 +371,8 @@ curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq .
 
 
 - A code flow starts with `GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256`.
 - A code flow starts with `GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256`.
 - Introspect a token: `POST /introspect` with `token` and client authentication.
 - Introspect a token: `POST /introspect` with `token` and client authentication.
+  A token whose subject is a disabled user returns `{"active": false}` like an
+  unknown token.
 - Revoke a token: `POST /revoke` with `token` and an optional `token_type_hint`.
 - Revoke a token: `POST /revoke` with `token` and an optional `token_type_hint`.
 - If every security endpoint returns `503`, check `/health/ready` and the
 - If every security endpoint returns `503`, check `/health/ready` and the
   Postgres and Redis connections.
   Postgres and Redis connections.

+ 13 - 3
docs/REDIS.md

@@ -22,20 +22,23 @@ differ.
 | --- | --- | --- |
 | --- | --- | --- |
 | `oauth:{state}:code:<hash>` | Single-use authorization code, PKCE-bound. | `ttl.code` (60s) |
 | `oauth:{state}:code:<hash>` | Single-use authorization code, PKCE-bound. | `ttl.code` (60s) |
 | `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access` (300s) |
 | `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access` (300s) |
+| `oauth:{state}:access:<hash>:act` | Token-exchange `act` claim sidecar. | Lives with the access record |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
 | `oauth:{state}:grant:<id>` | Grant-chain record. | Refresh lifetime |
 | `oauth:{state}:grant:<id>` | Grant-chain record. | Refresh lifetime |
 | `oauth:{state}:grant:<id>:tokens` | Set of member token hashes for cascade revoke. | Lives with the grant |
 | `oauth:{state}:grant:<id>:tokens` | Set of member token hashes for cascade revoke. | Lives with the grant |
 | `oauth:{state}:session:<id>` | Server-side login session. | `ttl.session` (12h) |
 | `oauth:{state}:session:<id>` | Server-side login session. | `ttl.session` (12h) |
 | `oauth:{state}:session:<id>:meta` | Session sidecar: `auth_time`, CSRF secret, bound `request_id`, and the bound post-logout client/URI. | `ttl.session` |
 | `oauth:{state}:session:<id>:meta` | Session sidecar: `auth_time`, CSRF secret, bound `request_id`, and the bound post-logout client/URI. | `ttl.session` |
 | `oauth:{state}:par:<id>` | PAR `request_uri` record. | `ttl.par` (90s) |
 | `oauth:{state}:par:<id>` | PAR `request_uri` record. | `ttl.par` (90s) |
-| `oauth:{state}:pending:<id>` | Validated pending-auth record. | Short, request-scoped |
+| `oauth:{state}:pending:<id>` | Validated pending-auth record. | `routes::PENDING_TTL` (300s) |
 | `oauth:{state}:device:<hash>` | Device-code record. | `ttl.device` (600s) |
 | `oauth:{state}:device:<hash>` | Device-code record. | `ttl.device` (600s) |
+| `oauth:{state}:device:<hash>:cnf` | Device sender-binding sidecar. | Lives with the device record |
+| `oauth:{state}:device:<hash>:poll` | Device poll-throttle marker. | Lives with the device record |
 | `oauth:{state}:user_code:<code-hash>` | User code to device-hash mapping. | `ttl.device` |
 | `oauth:{state}:user_code:<code-hash>` | User code to device-hash mapping. | `ttl.device` |
 | `oauth:{state}:dcr_iat:<hash>` | DCR initial-access token budget. | CLI `--ttl` |
 | `oauth:{state}:dcr_iat:<hash>` | DCR initial-access token budget. | CLI `--ttl` |
 | `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | Proof freshness window |
 | `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | Proof freshness window |
-| `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Assertion window |
+| `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Remaining assertion validity (`exp + 30s skew - now`), clamped `[1s, 360s]` (300s max lifetime + 2×30s skew) |
 | `oauth:{state}:rotation:<request-id>` | Rotation idempotency mapping. | `min(900s, refresh TTL)` |
 | `oauth:{state}:rotation:<request-id>` | Rotation idempotency mapping. | `min(900s, refresh TTL)` |
-| `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label). | Rate-limit window |
+| `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. | Time step window |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. | Time step window |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 
 
@@ -57,6 +60,13 @@ token state. This is what keeps two promises:
 The server asserts both properties at startup and refuses a prefix that lacks
 The server asserts both properties at startup and refuses a prefix that lacks
 its tag.
 its tag.
 
 
+## Record writes
+
+Every hash record is written through one atomic Lua script (`hset_record` →
+`HSET_TTL`): the fields and the `PEXPIRE` happen in a single script, so no
+record can ever be observed — or survive a crash — without its TTL. Replay
+markers are `SET NX PX` plus a durability barrier.
+
 ## Rotation, tombstones, and idempotency
 ## Rotation, tombstones, and idempotency
 
 
 Refresh rotation is a Lua compare-and-set keyed by a deterministic
 Refresh rotation is a Lua compare-and-set keyed by a deterministic

+ 39 - 8
docs/SECURITY.md

@@ -64,7 +64,7 @@ class, or runtime setting).
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
 | Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
 
 
-Last reviewed: 2026-10-05. File-mounted DB/Redis passwords, digest pinning,
+Last reviewed: 2026-10-09. File-mounted DB/Redis passwords, digest pinning,
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
 implemented in the shipped manifests and workflows.
 implemented in the shipped manifests and workflows.
 
 
@@ -74,6 +74,11 @@ implemented in the shipped manifests and workflows.
 - `unwrap`, `expect`, and `panic` are denied by lint in request paths.
 - `unwrap`, `expect`, and `panic` are denied by lint in request paths.
 - A raw token, secret, password, OTP, or client secret never appears in
 - A raw token, secret, password, OTP, or client secret never appears in
   Postgres, Redis, logs, an error body, or an evidence file.
   Postgres, Redis, logs, an error body, or an evidence file.
+- Log redaction masks `authorization`, `cookie`, `dpop`, `code`, `token`,
+  `client_secret`, `code_verifier`, `password`, `user_code`, `device_code`, and
+  `request_uri`, plus any `*_token`/`*_secret`/`*_password`/`*_code` key, in
+  headers, JSON, form bodies, and query strings; `code_challenge` and
+  `request_id` stay visible.
 - Secret comparisons are constant time.
 - Secret comparisons are constant time.
 
 
 ## Browser and intermediary caching
 ## Browser and intermediary caching
@@ -237,8 +242,13 @@ Reference: AES-256-GCM (`ring`), HKDF-SHA256, HMAC-SHA256.
   the inline value wins (`resolve_secret` in `src/config/build.rs`). The key ids
   the inline value wins (`resolve_secret` in `src/config/build.rs`). The key ids
   default to `v1` through `token_pepper_id` / `totp_key_id` / `pii_key_id`
   default to `v1` through `token_pepper_id` / `totp_key_id` / `pii_key_id`
   (`src/config/raw.rs`). Each purpose may retain one previous key
   (`src/config/raw.rs`). Each purpose may retain one previous key
-  (`*_previous_key` / `*_previous_key_file`) with an explicit, distinct
+  (  `*_previous_key` / `*_previous_key_file`) with an explicit, distinct
   `*_previous_key_id`; a previous key without its active key is a config error.
   `*_previous_key_id`; a previous key without its active key is a config error.
+- **Minimum length.** Every resolved master-key value — `token_pepper`,
+  `totp_encryption_key`, `pii_encryption_key`, and each retained
+  `*_previous_key`, inline or `*_FILE` — must be at least 32 bytes (256 bits).
+  Shorter material is rejected at load with `ConfigError::WeakKeyMaterial`,
+  which names the offending key.
 - The shipped cluster manifests never use the inline or environment forms: the
 - The shipped cluster manifests never use the inline or environment forms: the
   Secret file-mounts the keys `0400` on a tmpfs volume at `/run/secrets`, and
   Secret file-mounts the keys `0400` on a tmpfs volume at `/run/secrets`, and
   the loader reads them through `D_OAUTH_TOKEN_PEPPER_FILE`,
   the loader reads them through `D_OAUTH_TOKEN_PEPPER_FILE`,
@@ -293,7 +303,8 @@ configure one as fail-open is a load-time error.
 - A device code is consumed atomically on approval.
 - A device code is consumed atomically on approval.
 - DPoP and `private_key_jwt` `jti` values are recorded with `SET NX`, so a
 - DPoP and `private_key_jwt` `jti` values are recorded with `SET NX`, so a
   replayed proof or assertion is rejected. A `private_key_jwt` marker lives for
   replayed proof or assertion is rejected. A `private_key_jwt` marker lives for
-  the assertion's full remaining validity, bounded by the 300 s lifetime cap.
+  the assertion's full remaining validity, bounded by the 360 s bound (a 300 s
+  maximum assertion lifetime plus 2×30 s clock skew).
 - A TOTP code is recorded in a replay cache for its time-step window.
 - A TOTP code is recorded in a replay cache for its time-step window.
 
 
 ## Input and output safety
 ## Input and output safety
@@ -302,9 +313,12 @@ configure one as fail-open is a load-time error.
   There are no wildcards and no prefix matches.
   There are no wildcards and no prefix matches.
 - PKCE is `S256` only. A verifier cannot be omitted to downgrade.
 - PKCE is `S256` only. A verifier cannot be omitted to downgrade.
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
-  enabled`. Refresh can only narrow and re-checks the current client-registered
-  and user-entitled sets (a fresh Postgres read), so revoking a scope or an
-  entitlement drops it on the next refresh.
+  enabled`. Refresh can only narrow and re-checks the current `enabled` catalog
+  plus the current client-registered and user-entitled sets (fresh Postgres
+  reads): an omitted `scope` keeps the prior grant only while still enabled,
+  then silently narrows to the still-registered ∩ still-entitled intersection
+  (possibly empty); an explicit `scope` must be a subset of the prior grant and
+  of all three current sets.
 - The login, consent, and device pages set a strict CSP with
 - The login, consent, and device pages set a strict CSP with
   `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
   `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
   no open redirect.
   no open redirect.
@@ -320,8 +334,25 @@ configure one as fail-open is a load-time error.
   `id_token_hint`.
   `id_token_hint`.
 - Failed-login and lockout counters live in Redis, not Postgres, so a login
 - Failed-login and lockout counters live in Redis, not Postgres, so a login
   flood does not write rows.
   flood does not write rows.
-- Rate limiting is per IP and per client. Counters live in a Redis keyspace
-  separated from token state.
+- Rate limiting classifies by full request path: `/token`,
+  `/authorize`/`/consent`/`/device*`/`/introspect`/`/revoke`/`/par`,
+  `/login*`/`/logout`, and `/register*`; `/health`, `/.well-known`, and
+  `/assets` are unrated. Counters live in a Redis keyspace separated from token
+  state. A breach is `429` + `Retry-After`; a store outage or a missing peer
+  address fails closed `503`.
+- TOTP enrollment confirmation (`confirm_totp`) shares the login lockout counter
+  and policy: a failed code increments it, success clears it, and a locked
+  account rejects even a correct code — indistinguishably — until the window
+  expires.
+- A disabled subject is dead everywhere: introspection reports it inactive and
+  refresh rejects it with `invalid_grant` before minting; `user disable` first
+  purges every Redis record whose subject is the user (sessions + `:meta`, the
+  `loginfail` counter, authorization codes, device records + `:cnf`/`:poll` +
+  `user_code` mappings, grants via cascade revoke plus member `:act`, and
+  standalone access/refresh records), then flips Postgres `disabled`. A Redis
+  outage fails closed and leaves the user enabled. Records with no subject
+  (`par:`, `pending:`, `dpop_jti:`, `client_assertion_jti:`, `rotation:`,
+  `totp_replay:`, rate-limit counters) are untouched.
 - CORS is empty by default and never a wildcard.
 - CORS is empty by default and never a wildcard.
 - DCR is protected by a one-time initial-access token; open registration is off
 - DCR is protected by a one-time initial-access token; open registration is off
   by default.
   by default.

+ 2 - 1
src/routes.rs

@@ -1,7 +1,8 @@
 //! Router assembly: public listener routes plus the middleware chain.
 //! Router assembly: public listener routes plus the middleware chain.
 //!
 //!
 //! [`build_router`] mounts every endpoint behind one [`tower::ServiceBuilder`]
 //! [`build_router`] mounts every endpoint behind one [`tower::ServiceBuilder`]
-//! whose execution order is exactly {request-id → tracing/redaction →
+//! whose execution order (outer-to-inner) is exactly {request-id → CORS (when
+//! configured) → resilience (load-shed → concurrency-limit → timeout) →
 //! rate-limit → body limit → security headers}:
 //! rate-limit → body limit → security headers}:
 //!
 //!
 //! 1. [`middleware::request_id::RequestIdLayer`] mints/propagates
 //! 1. [`middleware::request_id::RequestIdLayer`] mints/propagates