Selaa lähdekoodia

Hardened tests

Efren Yevale Varela 3 viikkoa sitten
vanhempi
sitoutus
9452d765ec

+ 4 - 0
docs/SECURITY.md

@@ -143,6 +143,10 @@ Every sender-constrained grant requires a freshly verified proof on every use.
 - mTLS identity is a client certificate subject DN (`tls_client_auth`) or a
   certificate thumbprint (`self_signed_tls_client_auth`), each unique per the
   partial indexes in the schema.
+- A client certificate is matched only against the identity registered for the
+  requesting `client_id`, and only while inside its validity window. A
+  cross-wired or expired certificate is `invalid_client` and never binds a
+  `cnf`.
 - A DPoP proof is rejected if it is malformed, oversized, uses an unsupported
   algorithm, has the wrong `typ`, or has a bad or replayed `jti`, `htm`, `htu`,
   `iat`, or `ath`.

+ 50 - 4
src/middleware/ratelimit/test_support.rs

@@ -128,6 +128,16 @@ pub(super) fn peer(last: u8) -> SocketAddr {
   SocketAddr::from(([127, 0, 0, last], 40_000))
 }
 
+/// Connection and header extras for one test request.
+#[derive(Debug, Clone, Copy, Default)]
+pub(super) struct Extras<'a> {
+  /// Peer address recorded in `ConnectInfo`; `None` models a request the
+  /// limiter receives with no recorded peer.
+  pub peer: Option<SocketAddr>,
+  /// Additional request headers, such as a forged `X-Forwarded-For`.
+  pub headers: &'a [(&'a str, &'a str)],
+}
+
 /// Drive one request through the layered service from `peer_addr`.
 pub(super) async fn send<S>(
   service: S,
@@ -143,12 +153,48 @@ where
     + 'static,
   S::Future: Send + 'static,
 {
-  let mut request = Request::builder()
+  send_with(
+    service,
+    method,
+    uri,
+    body,
+    Extras {
+      peer: Some(peer_addr),
+      headers: &[],
+    },
+  )
+  .await
+}
+
+/// Drive one request through the layered service with [`Extras`].
+///
+/// `extras.peer == None` omits `ConnectInfo`, modeling a request whose peer
+/// the limiter cannot identify.
+pub(super) async fn send_with<S>(
+  service: S,
+  method: &str,
+  uri: &str,
+  body: &str,
+  extras: Extras<'_>,
+) -> Result<Response<Body>, Box<dyn Error>>
+where
+  S: Service<Request<Body>, Response = Response<Body>, Error = std::convert::Infallible>
+    + Clone
+    + Send
+    + 'static,
+  S::Future: Send + 'static,
+{
+  let mut builder = Request::builder()
     .method(method)
     .uri(uri)
-    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
-    .body(Body::from(body.to_owned()))?;
-  request.extensions_mut().insert(ConnectInfo(peer_addr));
+    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
+  for (name, value) in extras.headers {
+    builder = builder.header(*name, *value);
+  }
+  let mut request = builder.body(Body::from(body.to_owned()))?;
+  if let Some(peer_addr) = extras.peer {
+    request.extensions_mut().insert(ConnectInfo(peer_addr));
+  }
   Ok(service.oneshot(request).await?)
 }
 

+ 367 - 3
src/middleware/ratelimit/tests.rs

@@ -3,7 +3,7 @@
 //! Each test owns a throwaway `redis:7.2-alpine` container (AOF on), so
 //! counter keys never leak across tests. Fixtures live in [`super::test_support`].
 
-use std::net::SocketAddr;
+use std::net::{IpAddr, Ipv6Addr, SocketAddr};
 use std::time::Duration;
 
 use axum::http::StatusCode;
@@ -11,9 +11,10 @@ use serde_json::json;
 use tower::Layer;
 
 use super::RateLimitLayer;
+use super::identity::counter_key;
 use super::test_support::{
-  Passthrough, TestResult, live_state, peer, redis_container, retry_after, scan_keys, send,
-  test_config, unavailable_state,
+  Extras, Passthrough, TestResult, live_state, peer, redis_container, retry_after, scan_keys, send,
+  send_with, test_config, unavailable_state,
 };
 
 /// N in-window requests pass; the N+1th is `429` + `Retry-After`.
@@ -209,3 +210,366 @@ async fn ratelimit_leaves_unlisted_paths_untouched_on_store_outage() -> TestResu
   assert_eq!(response.status(), StatusCode::OK);
   Ok(())
 }
+
+/// Without a `trusted_proxy`, a forged `X-Forwarded-For` is never an identity:
+/// the limiter keys off the recorded peer, so rotating spoofed addresses
+/// cannot mint a fresh bucket and evade the limit.
+#[tokio::test]
+async fn ratelimit_ignores_spoofed_x_forwarded_for_without_trusted_proxy() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  let first = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(peer(1)),
+      headers: &[("x-forwarded-for", "198.51.100.10")],
+    },
+  )
+  .await?;
+  assert_eq!(first.status(), StatusCode::OK);
+
+  let blocked = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(peer(1)),
+      headers: &[("x-forwarded-for", "198.51.100.11")],
+    },
+  )
+  .await?;
+  assert_eq!(
+    blocked.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "rotating X-Forwarded-For must not evade the real peer's bucket"
+  );
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  assert_eq!(
+    keys,
+    vec![counter_key(&prefix, "ip", "127.0.0.1")],
+    "only the hashed real peer IP may be counted: {keys:?}"
+  );
+  for spoofed in ["198.51.100.10", "198.51.100.11"] {
+    assert!(
+      !keys.contains(&counter_key(&prefix, "ip", spoofed)),
+      "spoofed address became key material: {keys:?}"
+    );
+  }
+  Ok(())
+}
+
+/// The same rule for RFC 7239 `Forwarded`: with no `trusted_proxy` it can
+/// never select a bucket different from the recorded peer.
+#[tokio::test]
+async fn ratelimit_ignores_spoofed_forwarded_header_without_trusted_proxy() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  let first = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(peer(1)),
+      headers: &[("forwarded", "for=198.51.100.20;proto=https")],
+    },
+  )
+  .await?;
+  assert_eq!(first.status(), StatusCode::OK);
+
+  let blocked = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: Some(peer(1)),
+      headers: &[("forwarded", "for=198.51.100.21;proto=https")],
+    },
+  )
+  .await?;
+  assert_eq!(
+    blocked.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "rotating Forwarded must not evade the real peer's bucket"
+  );
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  assert_eq!(
+    keys,
+    vec![counter_key(&prefix, "ip", "127.0.0.1")],
+    "only the hashed real peer IP may be counted: {keys:?}"
+  );
+  Ok(())
+}
+
+/// An IPv6 peer owns a bucket distinct from IPv4: exhausting the IPv4 bucket
+/// never limits the same traffic from `::1`, and the reverse.
+#[tokio::test]
+async fn ratelimit_ipv6_peer_owns_a_distinct_bucket() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  let ipv4 = peer(1);
+  let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 40_000);
+
+  let v4_first = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    ipv4,
+  )
+  .await?;
+  assert_eq!(v4_first.status(), StatusCode::OK);
+  let v4_second = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    ipv4,
+  )
+  .await?;
+  assert_eq!(v4_second.status(), StatusCode::TOO_MANY_REQUESTS);
+
+  let v6_first = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    ipv6,
+  )
+  .await?;
+  assert_eq!(
+    v6_first.status(),
+    StatusCode::OK,
+    "an exhausted IPv4 bucket must not leak into the IPv6 peer's bucket"
+  );
+  let v6_second = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    ipv6,
+  )
+  .await?;
+  assert_eq!(
+    v6_second.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "the IPv6 peer's own bucket must still enforce the budget"
+  );
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  for expected in [
+    counter_key(&prefix, "ip", "127.0.0.1"),
+    counter_key(&prefix, "ip", "::1"),
+  ] {
+    assert!(
+      keys.contains(&expected),
+      "missing peer bucket {expected}: {keys:?}"
+    );
+  }
+  assert_eq!(
+    keys.len(),
+    2,
+    "IPv4 and IPv6 peers must not share a bucket: {keys:?}"
+  );
+  Ok(())
+}
+
+/// The per-IP budget stands alone: once a peer is over it, a brand-new
+/// `client_id` from that peer is still limited, and every bucket stays keyed
+/// separately.
+#[tokio::test]
+async fn ratelimit_per_ip_budget_blocks_fresh_client_from_same_ip() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let prefix = state.config.redis.ratelimit_key_prefix.clone();
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  let first = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code&client_id=client-a",
+    peer(1),
+  )
+  .await?;
+  assert_eq!(first.status(), StatusCode::OK);
+
+  let blocked = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code&client_id=client-b",
+    peer(1),
+  )
+  .await?;
+  assert_eq!(
+    blocked.status(),
+    StatusCode::TOO_MANY_REQUESTS,
+    "a fresh client from an exhausted peer must hit the per-IP budget"
+  );
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  for expected in [
+    counter_key(&prefix, "ip", "127.0.0.1"),
+    counter_key(&prefix, "client", "client-a"),
+    counter_key(&prefix, "client", "client-b"),
+  ] {
+    assert!(
+      keys.contains(&expected),
+      "missing independent bucket {expected}: {keys:?}"
+    );
+  }
+  assert_eq!(
+    keys.len(),
+    3,
+    "IP and client buckets must not collide: {keys:?}"
+  );
+  Ok(())
+}
+
+/// A breach advertises a positive whole-second wait no longer than the
+/// configured window.
+#[tokio::test]
+async fn ratelimit_retry_after_is_positive_and_bounded_by_window() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 1, "token_per_client": 1000}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  let first = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    peer(1),
+  )
+  .await?;
+  assert_eq!(first.status(), StatusCode::OK);
+
+  let blocked = send(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    peer(1),
+  )
+  .await?;
+  assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
+
+  let seconds = retry_after(&blocked).ok_or("429 must carry Retry-After")?;
+  assert!(
+    (1..=60).contains(&seconds),
+    "Retry-After must be within 1..=window seconds, got {seconds}"
+  );
+  Ok(())
+}
+
+/// `rate_limit.enabled = false` is an explicit pass-through: requests far past
+/// the configured budget are admitted and no limiter key is written.
+#[tokio::test]
+async fn ratelimit_disabled_never_limits_and_writes_no_keys() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": false, "window_secs": 60, "per_ip": 1, "token_per_client": 1}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  for attempt in 0..3 {
+    let response = send(
+      service.clone(),
+      "POST",
+      "/token",
+      "grant_type=authorization_code&client_id=client-a",
+      peer(1),
+    )
+    .await?;
+    assert_eq!(
+      response.status(),
+      StatusCode::OK,
+      "attempt {attempt} must never be limited while disabled"
+    );
+  }
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  assert!(
+    keys.is_empty(),
+    "a disabled limiter must not write counter keys: {keys:?}"
+  );
+  Ok(())
+}
+
+/// A request with no recorded peer fails closed: `503` with the contractual
+/// `Retry-After: 1`, and no counter key is written because no identity exists
+/// to count.
+#[tokio::test]
+async fn ratelimit_missing_peer_fails_closed_without_keys() -> TestResult {
+  let (_redis, url) = redis_container().await?;
+  let config = test_config(
+    &url,
+    json!({"enabled": true, "window_secs": 60, "per_ip": 10, "token_per_client": 10}),
+  )?;
+  let state = live_state(config, &url).await?;
+  let service = RateLimitLayer::new(state).layer(Passthrough);
+
+  let response = send_with(
+    service.clone(),
+    "POST",
+    "/token",
+    "grant_type=authorization_code",
+    Extras {
+      peer: None,
+      headers: &[],
+    },
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+  assert_eq!(
+    retry_after(&response),
+    Some(1),
+    "fail-closed response must carry the contractual Retry-After: 1"
+  );
+
+  let keys = scan_keys(&url, "oauth:{rl}:*").await?;
+  assert!(
+    keys.is_empty(),
+    "a request with no identity must not write counter keys: {keys:?}"
+  );
+  Ok(())
+}

+ 67 - 13
src/protocol/dcr/manage/support.rs

@@ -15,13 +15,15 @@ use testcontainers::{
 use time::OffsetDateTime;
 use uuid::Uuid;
 
-use super::super::{RegisterContext, RegisterRequest, RegisterStores, register_client};
+use super::super::{
+  RegisterContext, RegisterRequest, RegisterResponse, RegisterStores, register_client,
+};
 use super::{ManageContext, ManageError, ManageStores};
 use crate::{
   config::{Config, DurabilityMode},
   crypto::tokens as tokens_crypto,
   db::{redirect_repo, scope_repo, test_support::Db},
-  error::OAuthErrorCode,
+  error::{AppError, OAuthErrorCode},
   state::RedisConn,
   store::{Store, dcr as dcr_store},
   test_guard::{ContainerGuard, Lease},
@@ -215,7 +217,7 @@ async fn issue_iat(harness: &mut Harness, uses: u64) -> Result<String, TestError
 }
 
 /// A registration request against `redirect_uri` with DPoP binding.
-fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
+pub(super) fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
   RegisterRequest {
     redirect_uris: Some(vec![redirect_uri.to_owned()]),
     client_name: Some("dcr manage client".to_owned()),
@@ -258,15 +260,9 @@ pub(super) async fn register(
   })
 }
 
-/// Register one mTLS client through the production path.
-pub(super) async fn register_mtls(
-  harness: &mut Harness,
-  redirect_uri: &str,
-  subject_dn: &str,
-) -> Result<Registered, TestError> {
-  seed_redirect(&harness.pool, &harness.config.keys, redirect_uri).await?;
-  let iat = issue_iat(harness, 3).await?;
-  let request = RegisterRequest {
+/// Build an mTLS (`tls_client_auth`) registration body for `subject_dn`.
+pub(super) fn mtls_request(redirect_uri: &str, subject_dn: &str) -> RegisterRequest {
+  RegisterRequest {
     redirect_uris: Some(vec![redirect_uri.to_owned()]),
     client_name: Some("dcr manage mtls client".to_owned()),
     token_endpoint_auth_method: Some("tls_client_auth".to_owned()),
@@ -280,7 +276,18 @@ pub(super) async fn register_mtls(
     contacts: None,
     request_uris: None,
     software_id: None,
-  };
+  }
+}
+
+/// Register one mTLS client through the production path.
+pub(super) async fn register_mtls(
+  harness: &mut Harness,
+  redirect_uri: &str,
+  subject_dn: &str,
+) -> Result<Registered, TestError> {
+  seed_redirect(&harness.pool, &harness.config.keys, redirect_uri).await?;
+  let iat = issue_iat(harness, 3).await?;
+  let request = mtls_request(redirect_uri, subject_dn);
   let response = {
     let (mut stores, ctx) = register_parts(harness);
     register_client(&mut stores, &ctx, Some(&iat), &request)
@@ -294,6 +301,53 @@ pub(super) async fn register_mtls(
   })
 }
 
+/// Register one client through the production path with an explicit body,
+/// returning the raw application outcome so tests can assert typed errors.
+///
+/// The caller seeds any catalog redirect URI or scope first.
+pub(super) async fn try_register(
+  harness: &mut Harness,
+  request: &RegisterRequest,
+) -> Result<Result<RegisterResponse, AppError>, TestError> {
+  let iat = issue_iat(harness, 3).await?;
+  let outcome = {
+    let (mut stores, ctx) = register_parts(harness);
+    register_client(&mut stores, &ctx, Some(&iat), request).await
+  };
+  Ok(outcome)
+}
+
+/// Assert an application error is exactly the typed `code`/`status` contract.
+pub(super) fn expect_app_error(error: AppError, code: OAuthErrorCode, status: u16) -> TestResult {
+  if error.code() != code || error.status().as_u16() != status {
+    return Err(TestError::Unexpected(format!(
+      "wrong error: {} {} (expected {code} {status})",
+      error.code(),
+      error.status()
+    )));
+  }
+  Ok(())
+}
+
+/// Assert the SSRF guard denies `uri` per the documented fetch policy:
+/// `https` only, and never a private/loopback/link-local literal
+/// (docs/OAUTH.md "JAR and PAR", docs/SECURITY.md "PAR and JAR"). A URL
+/// that fails the scheme or IP-literal check is denied before any socket
+/// is opened, so this never performs network I/O for the fixture URLs.
+pub(super) async fn expect_ssrf_denied(uri: &str) -> TestResult {
+  use crate::protocol::ssrf::{SsrfError, fetch_guarded_bytes};
+
+  match fetch_guarded_bytes(uri).await {
+    Err(SsrfError::NonHttps | SsrfError::BlockedTarget) => Ok(()),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "unexpected guard failure for {uri}: {error}"
+    ))),
+    Ok(_) => Err(TestError::Unexpected(format!(
+      "guard fetched a forbidden request_uri: {uri}"
+    ))),
+  }
+}
+
 /// Assert a management failure is `401 invalid_client`.
 pub(super) fn expect_unauthorized(error: ManageError) -> TestResult {
   match error {

+ 52 - 1
src/protocol/dcr/manage/tests.rs

@@ -2,8 +2,10 @@
 
 use super::read_client;
 use super::support::{
-  TestResult, expect_forbidden, expect_unauthorized, harness, manage_parts, register,
+  TestError, TestResult, dpop_request, expect_forbidden, expect_ssrf_denied, expect_unauthorized,
+  harness, manage_parts, register, seed_redirect, try_register,
 };
+use crate::db::client_repo;
 
 #[tokio::test]
 async fn dcr_manage_read_returns_metadata() -> TestResult {
@@ -208,6 +210,55 @@ async fn dcr_manage_forbidden_renders_403() -> TestResult {
   Ok(())
 }
 
+#[tokio::test]
+async fn dcr_manage_register_request_uris_stored_verbatim() -> TestResult {
+  // RFC 7591 §2 treats `request_uris` as client metadata to store, not a URL
+  // to fetch at registration; this server stores it verbatim and enforces the
+  // SSRF policy only when a request_uri is resolved at `/authorize`
+  // (docs/OAUTH.md "JAR and PAR", docs/SECURITY.md "PAR and JAR": https only,
+  // no redirects, global IPs only). A plaintext scheme, a loopback literal,
+  // and a non-https scheme must therefore register cleanly and stay inert.
+  let (_guard, mut harness) = harness("catalog").await?;
+  let redirect = "https://app.example.test/cb";
+  seed_redirect(&harness.pool, &harness.config.keys, redirect).await?;
+  let request_uris = serde_json::json!([
+    "http://app.example.test/req.jwt",
+    "https://127.0.0.1/req.jwt",
+    "ftp://app.example.test/req.jwt"
+  ]);
+  let mut request = dpop_request(redirect, None);
+  request.request_uris = Some(request_uris.clone());
+
+  let response = match try_register(&mut harness, &request).await? {
+    Ok(response) => response,
+    Err(error) => {
+      return Err(TestError::Unexpected(format!(
+        "registration with request_uris rejected: {error}"
+      )));
+    }
+  };
+
+  let row =
+    client_repo::find_active_by_client_id(&harness.pool, &harness.config.keys, &response.client_id)
+      .await?
+      .ok_or_else(|| TestError::Unexpected("registered client row missing".to_owned()))?;
+  if row.request_uris.as_ref() != Some(&request_uris) {
+    return Err(TestError::Unexpected(format!(
+      "request_uris not stored verbatim: {:?}",
+      row.request_uris
+    )));
+  }
+
+  for uri in [
+    "http://app.example.test/req.jwt",
+    "https://127.0.0.1/req.jwt",
+    "ftp://app.example.test/req.jwt",
+  ] {
+    expect_ssrf_denied(uri).await?;
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn dcr_manage_update_bad_redirect_rejected() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;

+ 261 - 2
src/protocol/dcr/manage/tests_delete.rs

@@ -6,9 +6,11 @@ use uuid::Uuid;
 use super::delete::delete_client;
 use super::read_client;
 use super::support::{
-  TestError, TestResult, expect_forbidden, expect_unauthorized, harness, manage_parts, register,
+  Harness, TestError, TestResult, expect_app_error, expect_forbidden, expect_unauthorized, harness,
+  manage_parts, mtls_request, register, register_mtls, try_register,
 };
-use crate::db::client_repo;
+use crate::db::client_repo::{self, NewClient};
+use crate::error::OAuthErrorCode;
 use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenService};
 
 #[tokio::test]
@@ -222,6 +224,263 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
   Ok(())
 }
 
+#[tokio::test]
+async fn dcr_manage_delete_token_denials_match_read() -> TestResult {
+  // RFC 7592 §2.3: DELETE is authenticated by the registration access token.
+  // The same denial contract as GET applies: absent or unknown/deactivated
+  // client answers 401, a mismatched credential on an existing client answers
+  // 403. A successful DELETE invalidates the token, so replaying it answers
+  // 401 (the deactivated row no longer authorizes management).
+  let (_guard, mut harness) = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match delete_client(&mut stores, &ctx, &registered.client_id, None).await {
+      Ok(()) => {
+        return Err(TestError::Unexpected(
+          "tokenless DELETE succeeded".to_owned(),
+        ));
+      }
+      Err(error) => expect_unauthorized(error)?,
+    }
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match delete_client(
+      &mut stores,
+      &ctx,
+      "no-such-client",
+      Some(&registered.reg_token),
+    )
+    .await
+    {
+      Ok(()) => {
+        return Err(TestError::Unexpected(
+          "unknown-client DELETE succeeded".to_owned(),
+        ));
+      }
+      Err(error) => expect_unauthorized(error)?,
+    }
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match delete_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some("garbage-token-value"),
+    )
+    .await
+    {
+      Ok(()) => {
+        return Err(TestError::Unexpected(
+          "wrong-token DELETE succeeded".to_owned(),
+        ));
+      }
+      Err(error) => expect_forbidden(error)?,
+    }
+  }
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    delete_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match delete_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+    )
+    .await
+    {
+      Ok(()) => {
+        return Err(TestError::Unexpected(
+          "revoked token accepted on repeated DELETE".to_owned(),
+        ));
+      }
+      Err(error) => expect_unauthorized(error)?,
+    }
+  }
+  Ok(())
+}
+
+/// Load a client row regardless of its `active` flag.
+async fn find_any_row(
+  harness: &Harness,
+  client_id: &str,
+) -> Result<client_repo::Client, TestError> {
+  client_repo::list(&harness.pool, &harness.config.keys, true)
+    .await?
+    .into_iter()
+    .find(|row| row.client_id == client_id)
+    .ok_or_else(|| TestError::Unexpected(format!("client row {client_id} missing")))
+}
+
+/// Insert a `self_signed_tls_client_auth` client directly.
+///
+/// DCR's `RegisterRequest` has no certificate-thumbprint field, so the
+/// repository is the only seam that can express this mTLS identity; the row
+/// then goes through the real management DELETE path with the supplied
+/// registration token.
+async fn insert_thumbprint_client(
+  harness: &Harness,
+  thumbprint: &str,
+  registration_token_hash: &str,
+) -> Result<client_repo::Client, TestError> {
+  let client_id = crate::crypto::tokens::mint_token()
+    .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
+  let new = NewClient {
+    id: Uuid::new_v4(),
+    client_id,
+    client_secret_hash: None,
+    client_secret_expires_at: None,
+    client_name: "dcr manage thumbprint client".to_owned(),
+    grant_types: vec!["authorization_code".to_owned()],
+    response_types: vec!["code".to_owned()],
+    scope: String::new(),
+    token_endpoint_auth_method: "self_signed_tls_client_auth".to_owned(),
+    jwks: None,
+    jwks_uri: None,
+    tls_client_auth_subject_dn: None,
+    tls_client_auth_cert_thumbprint: Some(thumbprint.to_owned()),
+    request_uris: None,
+    contacts: None,
+    software_id: None,
+    dpop_bound_access_tokens: false,
+    registration_access_token_hash: Some(registration_token_hash.to_owned()),
+  };
+  client_repo::insert(
+    &harness.pool,
+    &harness.config.keys,
+    harness.config.pii_dual_write,
+    &new,
+  )
+  .await
+  .map_err(TestError::from)
+}
+
+#[tokio::test]
+async fn dcr_manage_delete_subject_dn_stays_reserved() -> TestResult {
+  // RFC 7592 §2.3 DELETE deactivates the client; it does not erase the row.
+  // mTLS identities stay unique per the partial indexes over every non-NULL
+  // identity, independent of `active` (docs/SECURITY.md "Sender constraints";
+  // docs/DATABASE.md "oauth_clients" and its edge cases). Re-registering the
+  // same subject DN must therefore stay a typed 400 invalid_client_metadata
+  // duplicate, while a different DN is unaffected.
+  let (_guard, mut harness) = harness("catalog").await?;
+  let redirect = "https://app.example.test/cb";
+  let subject_dn = "CN=reserved-dn, O=Example";
+  let first = register_mtls(&mut harness, redirect, subject_dn).await?;
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    delete_client(&mut stores, &ctx, &first.client_id, Some(&first.reg_token))
+      .await
+      .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
+  }
+
+  let inactive = find_any_row(&harness, &first.client_id).await?;
+  if inactive.active {
+    return Err(TestError::Unexpected(
+      "deleted client row still flagged active".to_owned(),
+    ));
+  }
+  if inactive.tls_client_auth_subject_dn.as_deref() != Some(subject_dn) {
+    return Err(TestError::Unexpected(format!(
+      "deactivated row lost its subject DN: {:?}",
+      inactive.tls_client_auth_subject_dn
+    )));
+  }
+
+  match try_register(&mut harness, &mtls_request(redirect, subject_dn)).await? {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "deactivated client's subject DN was freed".to_owned(),
+      ));
+    }
+    Err(error) => expect_app_error(error, OAuthErrorCode::InvalidClientMetadata, 400)?,
+  }
+
+  match try_register(
+    &mut harness,
+    &mtls_request(redirect, "CN=fresh-dn, O=Example"),
+  )
+  .await?
+  {
+    Ok(response) => {
+      if response.client_id == first.client_id {
+        return Err(TestError::Unexpected(
+          "fresh registration reused the deleted client_id".to_owned(),
+        ));
+      }
+    }
+    Err(error) => {
+      return Err(TestError::Unexpected(format!(
+        "unaffected subject DN rejected: {error}"
+      )));
+    }
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn dcr_manage_delete_thumbprint_stays_reserved() -> TestResult {
+  // Same identity contract for `self_signed_tls_client_auth`: the certificate
+  // thumbprint is a partial unique index over every non-NULL value with no
+  // `active` predicate (docs/DATABASE.md "oauth_clients"), so deactivation
+  // must not free it. The row is driven through the real management DELETE.
+  let (_guard, mut harness) = harness("catalog").await?;
+  let thumbprint = "x5t-s256-manage-thumbprint-fixture";
+
+  let raw = crate::crypto::tokens::mint_token()
+    .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
+  let hash = super::super::keyed_hash(&harness.config, &raw)
+    .map_err(|error| TestError::Unexpected(format!("hash failed: {error}")))?;
+  let first = insert_thumbprint_client(&harness, thumbprint, &hash).await?;
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    delete_client(&mut stores, &ctx, &first.client_id, Some(&raw))
+      .await
+      .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
+  }
+
+  let inactive = find_any_row(&harness, &first.client_id).await?;
+  if inactive.active {
+    return Err(TestError::Unexpected(
+      "deleted client row still flagged active".to_owned(),
+    ));
+  }
+  if inactive.tls_client_auth_cert_thumbprint.as_deref() != Some(thumbprint) {
+    return Err(TestError::Unexpected(
+      "deactivated row lost its certificate thumbprint".to_owned(),
+    ));
+  }
+
+  let second_raw = crate::crypto::tokens::mint_token()
+    .map_err(|error| TestError::Unexpected(format!("mint failed: {error}")))?;
+  let second_hash = super::super::keyed_hash(&harness.config, &second_raw)
+    .map_err(|error| TestError::Unexpected(format!("hash failed: {error}")))?;
+  match insert_thumbprint_client(&harness, thumbprint, &second_hash).await {
+    Ok(_) => Err(TestError::Unexpected(
+      "deactivated client's certificate thumbprint was freed".to_owned(),
+    )),
+    Err(TestError::App(crate::error::AppError::DuplicateClientIdentity)) => Ok(()),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "unexpected duplicate-identity failure: {error}"
+    ))),
+  }
+}
+
 #[tokio::test]
 async fn dcr_manage_token_survives_redis_flush() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;

+ 345 - 1
src/protocol/dcr/manage/tests_update.rs

@@ -1,11 +1,14 @@
 //! `test(dcr_manage)` update suite: full-replace PUT, rotation, redirects.
 
+use super::delete::delete_client;
 use super::read_client;
 use super::support::{
-  TestError, TestResult, harness, manage_parts, register, register_mtls, seed_redirect, seed_scope,
+  TestError, TestResult, expect_forbidden, expect_ssrf_denied, expect_unauthorized, harness,
+  manage_parts, register, register_mtls, seed_redirect, seed_scope,
 };
 use super::update::update_client;
 use crate::db::{client_repo, redirect_repo};
+use crate::error::OAuthErrorCode;
 
 /// Build a PUT body: full replacement metadata for `redirect_uris`.
 fn put_request(
@@ -30,6 +33,347 @@ fn put_request(
   }
 }
 
+/// Build a PUT body carrying `request_uris` (RFC 7591 §2 metadata).
+fn put_request_with_request_uris(
+  redirect_uris: Vec<String>,
+  request_uris: serde_json::Value,
+) -> crate::protocol::dcr::RegisterRequest {
+  let mut request = put_request(redirect_uris, Some("dcr manage client"), None);
+  request.request_uris = Some(request_uris);
+  request
+}
+
+#[tokio::test]
+async fn dcr_manage_update_rotated_token_forbidden_on_put_and_delete() -> TestResult {
+  // RFC 7592 §2.2 and Appendix A.1: a successful PUT issues a new registration
+  // access token and invalidates the previous one. The old token must fail for
+  // every management verb, not only the read the existing suite covers.
+  let (_guard, mut harness) = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
+
+  let first_put = put_request(
+    vec!["https://app.example.test/cb".to_owned()],
+    Some("rotated"),
+    None,
+  );
+  let rotated = {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+      &first_put,
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("first PUT failed: {error:?}")))?
+  };
+  let fresh = rotated
+    .registration_access_token
+    .clone()
+    .ok_or_else(|| TestError::Unexpected("PUT must rotate the registration token".to_owned()))?;
+  if fresh == registered.reg_token {
+    return Err(TestError::Unexpected(
+      "registration token was not rotated".to_owned(),
+    ));
+  }
+
+  let second_put = put_request(
+    vec!["https://app.example.test/cb".to_owned()],
+    Some("should not apply"),
+    None,
+  );
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+      &second_put,
+    )
+    .await
+    {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "superseded token accepted on PUT".to_owned(),
+        ));
+      }
+      Err(error) => expect_forbidden(error)?,
+    }
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match delete_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+    )
+    .await
+    {
+      Ok(()) => {
+        return Err(TestError::Unexpected(
+          "superseded token accepted on DELETE".to_owned(),
+        ));
+      }
+      Err(error) => expect_forbidden(error)?,
+    }
+  }
+
+  let active = client_repo::find_active_by_client_id(
+    &harness.pool,
+    &harness.config.keys,
+    &registered.client_id,
+  )
+  .await?;
+  if active.is_none() {
+    return Err(TestError::Unexpected(
+      "rejected DELETE deactivated the client".to_owned(),
+    ));
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    let reread = read_client(&mut stores, &ctx, &registered.client_id, Some(&fresh))
+      .await
+      .map_err(|error| TestError::Unexpected(format!("fresh-token read failed: {error:?}")))?;
+    if reread.client_name != "rotated" {
+      return Err(TestError::Unexpected(format!(
+        "fresh token read wrong state: {}",
+        reread.client_name
+      )));
+    }
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn dcr_manage_update_token_denials_match_read() -> TestResult {
+  // Manage denial contract (docs/OAUTH.md error table; manage.rs module doc):
+  // absent credentials or an unknown/deactivated client answer 401, a
+  // mismatched credential on an existing client answers 403. PUT obeys the
+  // same contract as GET. Registration tokens carry no expiry field in the
+  // schema, so revocation (a successful DELETE that deactivates the client)
+  // is the only invalidation path besides rotation.
+  let (_guard, mut harness) = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
+  let request = put_request(
+    vec!["https://app.example.test/cb".to_owned()],
+    Some("denied"),
+    None,
+  );
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match update_client(&mut stores, &ctx, &registered.client_id, None, &request).await {
+      Ok(_) => {
+        return Err(TestError::Unexpected("tokenless PUT succeeded".to_owned()));
+      }
+      Err(error) => expect_unauthorized(error)?,
+    }
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match update_client(
+      &mut stores,
+      &ctx,
+      "no-such-client",
+      Some(&registered.reg_token),
+      &request,
+    )
+    .await
+    {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "unknown-client PUT succeeded".to_owned(),
+        ));
+      }
+      Err(error) => expect_unauthorized(error)?,
+    }
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some("garbage-token-value"),
+      &request,
+    )
+    .await
+    {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "wrong-token PUT succeeded".to_owned(),
+        ));
+      }
+      Err(error) => expect_forbidden(error)?,
+    }
+  }
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    delete_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
+  }
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+      &request,
+    )
+    .await
+    {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "revoked token accepted on PUT".to_owned(),
+        ));
+      }
+      Err(error) => expect_unauthorized(error)?,
+    }
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn dcr_manage_update_rejects_jwks_and_jwks_uri_together() -> TestResult {
+  // RFC 7591 §2: "The jwks_uri and jwks parameters MUST NOT both be present in
+  // the same request or response." The PUT validation must reject the pair
+  // before any write, so the client and its registration token stay intact.
+  let (_guard, mut harness) = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
+  let mut request = put_request(
+    vec!["https://app.example.test/cb".to_owned()],
+    Some("should not apply"),
+    None,
+  );
+  request.jwks = Some(serde_json::json!({
+    "keys": [{"kty": "RSA", "n": "AQAB", "e": "AQAB"}]
+  }));
+  request.jwks_uri = Some("https://app.example.test/jwks.json".to_owned());
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    match update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+      &request,
+    )
+    .await
+    {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "PUT accepted jwks and jwks_uri together".to_owned(),
+        ));
+      }
+      Err(super::ManageError::Protocol(error)) => {
+        super::support::expect_app_error(error, OAuthErrorCode::InvalidClientMetadata, 400)?;
+      }
+      Err(super::ManageError::Forbidden) => {
+        return Err(TestError::Unexpected(
+          "valid token read as forbidden".to_owned(),
+        ));
+      }
+    }
+  }
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    let reread = read_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("post-reject read failed: {error:?}")))?;
+    if reread.client_name != "dcr manage client" {
+      return Err(TestError::Unexpected(format!(
+        "rejected PUT mutated the client: {}",
+        reread.client_name
+      )));
+    }
+    if reread.jwks.is_some() || reread.jwks_uri.is_some() {
+      return Err(TestError::Unexpected(
+        "rejected PUT persisted JWKS metadata".to_owned(),
+      ));
+    }
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn dcr_manage_update_request_uris_stored_verbatim() -> TestResult {
+  // Same RFC 7591 §2 metadata-passthrough contract as registration: PUT stores
+  // request_uris verbatim and never fetches them; the SSRF guard is the
+  // fetch-time enforcement point (docs/OAUTH.md "JAR and PAR",
+  // docs/SECURITY.md "PAR and JAR"). Full-replace semantics must persist the
+  // array exactly, including plaintext, loopback, and non-https values.
+  let (_guard, mut harness) = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
+  let request_uris = serde_json::json!([
+    "http://app.example.test/req.jwt",
+    "https://127.0.0.1/req.jwt",
+    "ftp://app.example.test/req.jwt"
+  ]);
+  let request = put_request_with_request_uris(
+    vec!["https://app.example.test/cb".to_owned()],
+    request_uris.clone(),
+  );
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    let response = update_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+      &request,
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("PUT with request_uris failed: {error:?}")))?;
+    if response.registration_access_token.is_none() {
+      return Err(TestError::Unexpected(
+        "successful PUT did not rotate the registration token".to_owned(),
+      ));
+    }
+  }
+
+  let row = client_repo::find_active_by_client_id(
+    &harness.pool,
+    &harness.config.keys,
+    &registered.client_id,
+  )
+  .await?
+  .ok_or_else(|| TestError::Unexpected("client row missing after PUT".to_owned()))?;
+  if row.request_uris.as_ref() != Some(&request_uris) {
+    return Err(TestError::Unexpected(format!(
+      "request_uris not stored verbatim on PUT: {:?}",
+      row.request_uris
+    )));
+  }
+
+  for uri in [
+    "http://app.example.test/req.jwt",
+    "https://127.0.0.1/req.jwt",
+    "ftp://app.example.test/req.jwt",
+  ] {
+    expect_ssrf_denied(uri).await?;
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn dcr_manage_update_full_replace_rotates() -> TestResult {
   let (_guard, mut harness) = harness("catalog").await?;

+ 145 - 0
src/protocol/device/tests.rs

@@ -643,3 +643,148 @@ async fn test_device_authorize_eleventh_bad_guess_rate_limited() -> TestResult {
     )),
   }
 }
+
+/// RFC 8628 §3.2: `verification_uri_complete` carries the `user_code` for
+/// non-textual transmission (QR), while the short `verification_uri` the
+/// user types stays code-free (RFC 8628 §3.3).
+#[tokio::test]
+async fn test_device_authorize_verification_uri_complete_carries_user_code() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("complete");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read",
+  )
+  .await?;
+
+  let key = fresh_key()?;
+  let htu = device_htu(&h.config);
+  let proof = mint_proof(&key, &htu, &unique("jti"), now_unix())?;
+  let inputs = DeviceHttpInputs {
+    client_id: client_id.as_str(),
+    scope: None,
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: Some(proof.as_str()),
+    peer_certs: None,
+  };
+  let response = issue(&mut h, &inputs)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("issue rejected: {error}")))?;
+
+  let wire = serde_json::to_value(&response)
+    .map_err(|error| TestError::Unexpected(format!("serialize response: {error}")))?;
+  let complete = wire
+    .get("verification_uri_complete")
+    .and_then(serde_json::Value::as_str)
+    .ok_or_else(|| {
+      TestError::Unexpected("verification_uri_complete must be on the wire".to_owned())
+    })?;
+  let (base, query) = complete
+    .split_once('?')
+    .ok_or_else(|| TestError::Unexpected("complete URI must carry a query".to_owned()))?;
+  if base != response.verification_uri {
+    return Err(TestError::Unexpected(
+      "complete URI must extend verification_uri".to_owned(),
+    ));
+  }
+  let expected_query = format!("user_code={}", response.user_code);
+  if query != expected_query {
+    return Err(TestError::Unexpected(format!(
+      "complete URI must carry user_code={}, got {query}",
+      response.user_code
+    )));
+  }
+
+  if response.verification_uri.contains('?')
+    || response
+      .verification_uri
+      .contains(response.user_code.as_str())
+  {
+    return Err(TestError::Unexpected(
+      "verification_uri must stay code-free".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// RFC 8628 §3.3 and §5.1: every malformed or unknown user code is the same
+/// generic miss — never an error, a panic, or a signal that separates a
+/// guess from a real code. RFC 8628 §6.1 makes user-supplied punctuation
+/// optional, so case and separators do not change identity.
+#[tokio::test]
+async fn test_device_authorize_user_code_misses_are_generic_and_normalized() -> TestResult {
+  let (_container, mut h) = harness().await?;
+
+  for raw in [
+    "",
+    "ABCD",
+    "ABCD-EFGHI",
+    "IIII-IIII",
+    "0000-1111",
+    "ZZZZ-9999",
+    "ΩΩΩΩ-ΩΩΩΩ",
+    "ZZZZ-9999-AAAA-BBBB",
+  ] {
+    let miss =
+      crate::ui::device::lookup_pending(&mut h.conn, &h.store, &h.config.keys, raw).await?;
+    if miss.is_some() {
+      return Err(TestError::Unexpected(format!("{raw:?} must not resolve")));
+    }
+  }
+
+  let client_id = unique("normalize");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read",
+  )
+  .await?;
+  let key = fresh_key()?;
+  let htu = device_htu(&h.config);
+  let proof = mint_proof(&key, &htu, &unique("jti"), now_unix())?;
+  let inputs = DeviceHttpInputs {
+    client_id: client_id.as_str(),
+    scope: None,
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: Some(proof.as_str()),
+    peer_certs: None,
+  };
+  let response = issue(&mut h, &inputs)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("issue rejected: {error}")))?;
+
+  let canonical =
+    crate::ui::device::lookup_pending(&mut h.conn, &h.store, &h.config.keys, &response.user_code)
+      .await?
+      .ok_or_else(|| TestError::Unexpected("a fresh code must resolve".to_owned()))?;
+  let variants = [
+    response.user_code.to_lowercase(),
+    response.user_code.replace('-', " "),
+    response.user_code.replace('-', ""),
+  ];
+  for variant in variants {
+    let resolved =
+      crate::ui::device::lookup_pending(&mut h.conn, &h.store, &h.config.keys, &variant)
+        .await?
+        .ok_or_else(|| TestError::Unexpected(format!("{variant:?} must resolve")))?;
+    if resolved.device_hash != canonical.device_hash || resolved.client_id != client_id {
+      return Err(TestError::Unexpected(
+        "code formatting must not change identity".to_owned(),
+      ));
+    }
+  }
+  Ok(())
+}

+ 4 - 0
src/protocol/introspect.rs

@@ -48,6 +48,10 @@ pub const BEARER: &str = "Bearer";
 #[derive(Debug, Deserialize)]
 pub struct IntrospectForm {
   /// The token to inspect (required).
+  ///
+  /// Defaulted so a missing field reaches [`introspect`] and renders the
+  /// RFC 7662 §2.3 `invalid_request` (400) instead of an extractor rejection.
+  #[serde(default)]
   pub token: String,
   /// Optional hint about the token type.
   #[serde(default)]

+ 431 - 8
src/protocol/introspect/tests.rs

@@ -40,7 +40,11 @@ use crate::{
   config::Config,
   crypto::secret::{Argon2Params, hash_secret},
   db::{NewClient, TokenEndpointAuthMethod, client_repo, test_support::migrated_pool},
-  protocol::token::{ClientKind, MintRequest, SenderProof, TokenService},
+  protocol::{
+    client_auth::ClientAuthRequest,
+    revoke::{RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens},
+    token::{ClientKind, MintRequest, SenderProof, TokenService},
+  },
   state::{AppState, Clock, RedisConn},
   store::Store,
   test_guard::{ContainerGuard, Lease},
@@ -153,10 +157,11 @@ fn token_service(h: &Harness) -> TestResult<TokenService> {
   Ok(TokenService::new(&h.config.ttl, &h.config.keys)?)
 }
 
-/// Mint one sender-constrained access token owned by `client_id`.
-async fn mint_constrained(
+/// Mint one token pair owned by `client_id` with an explicit sender proof.
+async fn mint_bound(
   h: &mut Harness,
   client_id: &str,
+  proof: SenderProof,
 ) -> TestResult<crate::protocol::token::MintedTokens> {
   let service = token_service(h)?;
   Ok(
@@ -170,10 +175,7 @@ async fn mint_constrained(
           subject: "user-1".to_owned(),
           scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
-          proof: SenderProof {
-            dpop_jkt: Some("jkt-thumbnail".to_owned()),
-            mtls_x5t_s256: Some("x5t-thumbnail".to_owned()),
-          },
+          proof,
         },
         now_unix(),
       )
@@ -181,6 +183,22 @@ async fn mint_constrained(
   )
 }
 
+/// Mint one sender-constrained access token owned by `client_id`.
+async fn mint_constrained(
+  h: &mut Harness,
+  client_id: &str,
+) -> TestResult<crate::protocol::token::MintedTokens> {
+  mint_bound(
+    h,
+    client_id,
+    SenderProof {
+      dpop_jkt: Some("jkt-thumbnail".to_owned()),
+      mtls_x5t_s256: Some("x5t-thumbnail".to_owned()),
+    },
+  )
+  .await
+}
+
 /// Drive the core with `client_secret_post` credentials.
 async fn run_core(
   h: &mut Harness,
@@ -230,7 +248,11 @@ fn basic_header(client_id: &str, secret: &str) -> String {
   format!("Basic {encoded}")
 }
 
-async fn post_form(app: Router, auth: Option<&str>, body: &str) -> TestResult<(StatusCode, Value)> {
+async fn post_form_bytes(
+  app: Router,
+  auth: Option<&str>,
+  body: &str,
+) -> TestResult<(StatusCode, Vec<u8>)> {
   let mut builder = Request::builder()
     .method("POST")
     .uri("/introspect")
@@ -242,6 +264,11 @@ async fn post_form(app: Router, auth: Option<&str>, body: &str) -> TestResult<(S
   let response = app.oneshot(request).await?;
   let status = response.status();
   let bytes = response.into_body().collect().await?.to_bytes();
+  Ok((status, bytes.to_vec()))
+}
+
+async fn post_form(app: Router, auth: Option<&str>, body: &str) -> TestResult<(StatusCode, Value)> {
+  let (status, bytes) = post_form_bytes(app, auth, body).await?;
   Ok((status, serde_json::from_slice(&bytes)?))
 }
 
@@ -411,3 +438,399 @@ async fn test_introspect_revoked_token_is_inactive() -> TestResult {
   assert_eq!(response, IntrospectionResponse::inactive());
   Ok(())
 }
+
+#[tokio::test]
+async fn test_introspect_missing_token_form_is_400_invalid_request() -> TestResult {
+  let (_container, h) = harness().await?;
+  let client_id = unique("missing-token");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretBasic,
+    Some("secret-mt"),
+  )
+  .await?;
+
+  let auth = basic_header(&client_id, "secret-mt");
+  let (status, bytes) = post_form_bytes(
+    router(&h),
+    Some(&auth),
+    "token_type_hint=access_token&client_secret=secret-mt",
+  )
+  .await?;
+
+  // RFC 7662 §2.1: `token` is REQUIRED; §2.3: a malformed request is
+  // `invalid_request` (HTTP 400), never a silent inactive 200.
+  assert_eq!(
+    status,
+    StatusCode::BAD_REQUEST,
+    "a missing token parameter must be invalid_request 400"
+  );
+  let body: Value = serde_json::from_slice(&bytes)?;
+  assert_eq!(
+    body.get("error").and_then(Value::as_str),
+    Some("invalid_request")
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_empty_token_is_400_invalid_request() -> TestResult {
+  let (_container, h) = harness().await?;
+  let client_id = unique("empty-token");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretBasic,
+    Some("secret-et"),
+  )
+  .await?;
+
+  let auth = basic_header(&client_id, "secret-et");
+  let (status, bytes) = post_form_bytes(
+    router(&h),
+    Some(&auth),
+    "token=&token_type_hint=access_token",
+  )
+  .await?;
+
+  // RFC 7662 §2.1: an empty `token` is no token at all, so §2.3's
+  // `invalid_request` applies rather than the inactive response.
+  assert_eq!(
+    status,
+    StatusCode::BAD_REQUEST,
+    "an empty token must be invalid_request 400"
+  );
+  let body: Value = serde_json::from_slice(&bytes)?;
+  assert_eq!(
+    body.get("error").and_then(Value::as_str),
+    Some("invalid_request")
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_refresh_token_reports_active_shape() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("refresh-shape");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-rs"),
+  )
+  .await?;
+
+  let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
+  let before = now_unix();
+  let response = run_core(
+    &mut h,
+    &minted.refresh_token,
+    Some("refresh_token"),
+    &client_id,
+    "secret-rs",
+  )
+  .await?;
+
+  assert!(response.active, "a live refresh token must be active");
+  assert_eq!(response.scope.as_deref(), Some("orders:read"));
+  assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
+  assert_eq!(response.sub.as_deref(), Some("user-1"));
+  // RFC 7662 §2.2: `token_type` is OPTIONAL; RFC 6749 §5.1 defines a token
+  // type only for access tokens, so a refresh token reports none.
+  assert_eq!(response.token_type, None);
+  assert_eq!(response.cnf, None, "an unbound token has no confirmation");
+  let iat = response.iat.ok_or("iat missing")?;
+  let exp = response.exp.ok_or("exp missing")?;
+  assert!(iat < exp, "iat must precede exp");
+  assert!(exp > before, "exp must be in the future");
+  let ttl = i64::try_from(h.config.ttl.refresh.as_secs())?;
+  assert_eq!(exp - iat, ttl, "window must equal the refresh TTL");
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("dpop-bound");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-dp"),
+  )
+  .await?;
+
+  let minted = mint_bound(
+    &mut h,
+    &client_id,
+    SenderProof {
+      dpop_jkt: Some("jkt-dpop-only".to_owned()),
+      mtls_x5t_s256: None,
+    },
+  )
+  .await?;
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-dp",
+  )
+  .await?;
+
+  // RFC 9449 §5/§6: a DPoP-bound token confirms the key with `cnf.jkt`.
+  let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
+  assert_eq!(cnf.jkt.as_deref(), Some("jkt-dpop-only"));
+  assert_eq!(cnf.x5t_s256, None, "no mTLS binding was proven");
+  let json = serde_json::to_value(&response)?;
+  let cnf_json = json
+    .get("cnf")
+    .and_then(Value::as_object)
+    .ok_or("cnf member missing")?;
+  assert_eq!(
+    cnf_json.get("jkt").and_then(Value::as_str),
+    Some("jkt-dpop-only")
+  );
+  assert!(
+    !cnf_json.contains_key("x5t#S256"),
+    "the mTLS thumbprint must not leak into a DPoP-only response"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_mtls_bound_token_reports_x5t_only() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("mtls-bound");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-mtls"),
+  )
+  .await?;
+
+  let minted = mint_bound(
+    &mut h,
+    &client_id,
+    SenderProof {
+      dpop_jkt: None,
+      mtls_x5t_s256: Some("x5t-mtls-only".to_owned()),
+    },
+  )
+  .await?;
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-mtls",
+  )
+  .await?;
+
+  // RFC 8705 §3.1: an mTLS-bound token confirms the certificate with
+  // `cnf."x5t#S256"`.
+  let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
+  assert_eq!(cnf.x5t_s256.as_deref(), Some("x5t-mtls-only"));
+  assert_eq!(cnf.jkt, None, "no DPoP binding was proven");
+  let json = serde_json::to_value(&response)?;
+  let cnf_json = json
+    .get("cnf")
+    .and_then(Value::as_object)
+    .ok_or("cnf member missing")?;
+  assert_eq!(
+    cnf_json.get("x5t#S256").and_then(Value::as_str),
+    Some("x5t-mtls-only")
+  );
+  assert!(
+    !cnf_json.contains_key("jkt"),
+    "the DPoP thumbprint must not leak into an mTLS-only response"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_unknown_hint_is_advisory() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("unknown-hint");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-uh"),
+  )
+  .await?;
+
+  let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("password"),
+    &client_id,
+    "secret-uh",
+  )
+  .await?;
+
+  // RFC 7662 §2.1: an unknown `token_type_hint` is advisory only; the server
+  // must not reject the request and must still resolve the token.
+  assert!(
+    response.active,
+    "an unknown hint must not inactivate the token"
+  );
+  assert_eq!(response.token_type.as_deref(), Some("Bearer"));
+  assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_wrong_hint_still_resolves_refresh() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("wrong-hint");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-wh"),
+  )
+  .await?;
+
+  let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
+  let response = run_core(
+    &mut h,
+    &minted.refresh_token,
+    Some("access_token"),
+    &client_id,
+    "secret-wh",
+  )
+  .await?;
+
+  // RFC 7662 §2.1: a hint that misses must fall back to the other token type,
+  // so the refresh token is still found and reported active.
+  assert!(response.active, "the hint must not hide the refresh token");
+  assert_eq!(response.token_type, None);
+  assert_eq!(response.scope.as_deref(), Some("orders:read"));
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_active_response_leaks_no_token_or_secret() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("no-leak");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretBasic,
+    Some("leak-secret"),
+  )
+  .await?;
+
+  let minted = mint_bound(&mut h, &client_id, SenderProof::none()).await?;
+  let auth = basic_header(&client_id, "leak-secret");
+  let (status, bytes) = post_form_bytes(
+    router(&h),
+    Some(&auth),
+    &format!("token={}", minted.access_token),
+  )
+  .await?;
+
+  assert_eq!(status, StatusCode::OK);
+  let raw = String::from_utf8(bytes)?;
+  assert!(
+    raw.contains("\"active\":true"),
+    "expected an active response"
+  );
+  assert!(
+    !raw.contains(&minted.access_token),
+    "the presented token must never be echoed"
+  );
+  assert!(
+    !raw.contains(&minted.refresh_token),
+    "the sibling refresh token must never leak"
+  );
+  assert!(
+    !raw.contains("leak-secret"),
+    "the client secret must never leak"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("revoke-then-introspect");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-ri"),
+  )
+  .await?;
+  let minted = mint_bound(
+    &mut h,
+    &client_id,
+    SenderProof {
+      dpop_jkt: Some("jkt-revoked".to_owned()),
+      mtls_x5t_s256: None,
+    },
+  )
+  .await?;
+
+  let outcome = {
+    let service = token_service(&h)?;
+    let auth = ClientAuthRequest {
+      basic_header: None,
+      client_id: Some(client_id.as_str()),
+      body_secret: Some(SecretString::from("secret-ri")),
+      assertion_type: None,
+      assertion: None,
+      peer_certs: None,
+    };
+    let mut stores = RevokeStores {
+      pool: &h.pool,
+      keys: &h.config.keys,
+      conn: &mut h.conn,
+      store: &h.store,
+      service: &service,
+    };
+    let ctx = RevokeContext {
+      token_endpoint: "https://auth.example.test/token",
+      now: OffsetDateTime::now_utc(),
+      request_id: Some("req-introspect-revoke"),
+    };
+    let request = RevokeRequest {
+      token: &minted.access_token,
+      token_type_hint: None,
+      auth: &auth,
+    };
+    revoke_tokens(&mut stores, &ctx, &request).await?
+  };
+  assert_eq!(
+    outcome,
+    RevokeOutcome::Revoked,
+    "the owner must revoke its own DPoP-bound token"
+  );
+
+  // RFC 7009 §2.2: after revocation the token is no longer usable, so
+  // RFC 7662 §2.2 introspection reports the inactive shape.
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-ri",
+  )
+  .await?;
+  assert!(!response.active, "a revoked token must not be active");
+  assert_eq!(response, IntrospectionResponse::inactive());
+  Ok(())
+}

+ 4 - 0
src/protocol/mtls.rs

@@ -42,6 +42,9 @@ pub enum MtlsError {
   /// The presented bytes are not a parseable X.509 certificate.
   #[error("client certificate could not be parsed")]
   MalformedCertificate,
+  /// The presented certificate is outside its validity window.
+  #[error("client certificate is outside its validity period")]
+  ExpiredCertificate,
   /// No active client has this `client_id`.
   #[error("unknown client")]
   UnknownClient,
@@ -71,6 +74,7 @@ impl MtlsError {
       | Self::UnknownCertificate
       | Self::AmbiguousSubjectDn
       | Self::MalformedCertificate
+      | Self::ExpiredCertificate
       | Self::UnknownClient
       | Self::UnexpectedMethod => AppError::invalid_client_non_basic(),
     }

+ 11 - 0
src/protocol/mtls/cert.rs

@@ -24,3 +24,14 @@ pub fn subject_dn(der: &[u8]) -> Result<String, MtlsError> {
   let (_, cert) = X509Certificate::from_der(der).map_err(|_| MtlsError::MalformedCertificate)?;
   Ok(cert.subject().to_string())
 }
+
+/// Reject a leaf outside its validity window (RFC 5280 §6.3.3 path
+/// validation): an expired or not-yet-valid certificate must never bind a
+/// `cnf`, whichever method matched its identity.
+pub fn ensure_within_validity(der: &[u8]) -> Result<(), MtlsError> {
+  let (_, cert) = X509Certificate::from_der(der).map_err(|_| MtlsError::MalformedCertificate)?;
+  if !cert.validity().is_valid() {
+    return Err(MtlsError::ExpiredCertificate);
+  }
+  Ok(())
+}

+ 3 - 1
src/protocol/mtls/select.rs

@@ -11,7 +11,7 @@ use subtle::ConstantTimeEq;
 
 use super::{
   MtlsCnf, MtlsError,
-  cert::{subject_dn, thumbprint_b64url},
+  cert::{ensure_within_validity, subject_dn, thumbprint_b64url},
 };
 use crate::db::client_repo::{self, Client, TokenEndpointAuthMethod};
 use crate::keys::KeyRing;
@@ -59,6 +59,7 @@ pub async fn authenticate(
   match method {
     TokenEndpointAuthMethod::SelfSignedTlsClientAuth => {
       let der = leaf.ok_or(MtlsError::MissingCertificate)?;
+      ensure_within_validity(der.as_ref())?;
       let thumbprint = thumbprint_b64url(der.as_ref());
       let registered = client
         .tls_client_auth_cert_thumbprint
@@ -73,6 +74,7 @@ pub async fn authenticate(
     }
     TokenEndpointAuthMethod::TlsClientAuth => {
       let der = leaf.ok_or(MtlsError::MissingCertificate)?;
+      ensure_within_validity(der.as_ref())?;
       let dn = subject_dn(der.as_ref())?;
       let registered = client
         .tls_client_auth_subject_dn

+ 33 - 2
src/protocol/revoke/tests.rs

@@ -9,7 +9,10 @@ mod auth;
 mod cascade;
 
 use std::{
-  sync::atomic::{AtomicU64, Ordering},
+  sync::{
+    Arc,
+    atomic::{AtomicBool, AtomicU64, Ordering},
+  },
   time::Duration,
 };
 
@@ -27,7 +30,7 @@ use crate::{
     client_auth::ClientAuthRequest,
     token::{ClientKind, MintRequest, SenderProof, TokenService, TokenServiceError},
   },
-  state::RedisConn,
+  state::{AppState, Clock, RedisConn},
   store::{Store, StoreError},
   test_guard::{ContainerGuard, Lease, Shared},
 };
@@ -127,6 +130,34 @@ fn lazy_pool() -> Result<sqlx::PgPool, TestError> {
   )
 }
 
+/// An [`AppState`] for handler-level tests whose Postgres and Redis handles are
+/// lazy and never dialed: the missing-`token` path is rejected before any
+/// datastore call.
+fn handler_state() -> Result<AppState, TestError> {
+  let fixture = serde_json::json!({
+    "issuer": "https://auth.example.test",
+    "environment": "development",
+    "tls": {"cert_path": "/tmp/revoke-test-cert.pem", "key_path": "/tmp/revoke-test-key.pem"},
+    "database": {"url": "postgres://127.0.0.1:1/revoke"},
+    "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
+    "token_pepper": "revoke-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "revoke-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "revoke-test-pii-key-0123456789abcdef",
+  });
+  let source = ::config::Config::try_from(&fixture)
+    .map_err(|error| TestError::Unexpected(format!("config fixture: {error:?}")))?;
+  let config = crate::config::Config::from_source(source)
+    .map_err(|error| TestError::Unexpected(format!("config fixture: {error:?}")))?;
+  Ok(AppState {
+    pool: lazy_pool()?,
+    redis: lazy_redis()?,
+    config: Arc::new(config),
+    keys: Arc::new(test_keys()?),
+    transition_unsafe: Arc::new(AtomicBool::new(false)),
+    clock: Clock::system(),
+  })
+}
+
 static TEST_NONCE: AtomicU64 = AtomicU64::new(70_000);
 
 /// A label no other test uses, so grants/clients never collide.

+ 43 - 3
src/protocol/revoke/tests/auth.rs

@@ -1,16 +1,23 @@
 //! Revocation request validation: unauthenticated `401`, missing token `400`.
 
 use axum::{
-  http::{StatusCode, header},
+  Router,
+  body::Body,
+  http::{Request, StatusCode, header},
   response::IntoResponse,
+  routing::post,
 };
+use http_body_util::BodyExt as _;
+use serde_json::Value;
+use tower::ServiceExt as _;
 
 use super::{
-  TestError, TestResult, lazy_pool, lazy_redis, no_credentials, service, store, test_keys,
+  TestError, TestResult, handler_state, lazy_pool, lazy_redis, no_credentials, service, store,
+  test_keys,
 };
 use crate::{
   error::OAuthErrorCode,
-  protocol::revoke::{RevokeContext, RevokeRequest, RevokeStores, revoke_tokens},
+  protocol::revoke::{RevokeContext, RevokeRequest, RevokeStores, revoke_handler, revoke_tokens},
 };
 
 /// An unauthenticated request fails closed with `401 invalid_client`.
@@ -103,3 +110,36 @@ async fn revoke_missing_token_is_invalid_request() -> TestResult {
     )),
   }
 }
+
+/// A request missing the `token` form field is `400 invalid_request`
+/// (RFC 7009 §2.1: `token` is REQUIRED; §2.2.1 defines the error response).
+#[tokio::test]
+async fn revoke_missing_token_form_is_400_invalid_request() -> TestResult {
+  let app = Router::new()
+    .route("/revoke", post(revoke_handler))
+    .with_state(handler_state()?);
+  let request = Request::post("/revoke")
+    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+    .header(header::AUTHORIZATION, "Basic cmV2b2tlLXRlc3Q6c2VjcmV0")
+    .body(Body::from("token_type_hint=access_token"))
+    .map_err(|error| TestError::Unexpected(format!("request build failed: {error}")))?;
+  let response = match app.oneshot(request).await {
+    Ok(response) => response,
+    Err(never) => match never {},
+  };
+
+  if response.status() != StatusCode::BAD_REQUEST {
+    return Err(TestError::Unexpected(format!(
+      "a missing token parameter must be 400, got {}",
+      response.status()
+    )));
+  }
+  let bytes = response.into_body().collect().await?.to_bytes();
+  let body: Value = serde_json::from_slice(&bytes)?;
+  if body.get("error").and_then(Value::as_str) != Some("invalid_request") {
+    return Err(TestError::Unexpected(
+      "a missing token parameter must render invalid_request".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 109 - 0
src/protocol/revoke/tests/cascade.rs

@@ -224,3 +224,112 @@ async fn revoke_unsupported_hint_is_rejected() -> TestResult {
   }
   Ok(())
 }
+
+/// A `refresh_token` hint on an access token still revokes it: the hint only
+/// guides the lookup (RFC 7009 §2.1), and the located token's real type
+/// decides the scope of revocation (revoke.rs: an access token revokes only
+/// itself, so the sibling refresh token survives).
+#[tokio::test]
+async fn revoke_refresh_hint_on_access_token_still_revokes_it() -> TestResult {
+  let (_redis, mut conn, store) = test_redis().await?;
+  let service = service()?;
+  let client = unique("client");
+  let minted = service
+    .mint(
+      &mut conn,
+      &store,
+      &mint_request(&client, &unique("grant")),
+      now_unix(),
+    )
+    .await?;
+
+  let outcome = revoke_owned(
+    &mut conn,
+    &store,
+    &service,
+    &OwnedToken {
+      owner_client_id: &client,
+      token: &minted.access_token,
+      token_type_hint: Some(HINT_REFRESH),
+    },
+  )
+  .await?;
+  if outcome != RevokeOutcome::Revoked {
+    return Err(TestError::Unexpected(
+      "the misplaced hint must not block revocation".to_owned(),
+    ));
+  }
+  if service
+    .find_access(&mut conn, &store, &minted.access_token)
+    .await?
+    .is_some()
+  {
+    return Err(TestError::Unexpected(
+      "the access token must be gone after revocation".to_owned(),
+    ));
+  }
+  if service
+    .find_refresh(&mut conn, &store, &minted.refresh_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "the hint must not extend revocation beyond an access token".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Revoking another client's refresh token is a silent no-op: `NoOp`, the
+/// refresh token stays resolvable, and its access sibling is untouched too.
+#[tokio::test]
+async fn revoke_foreign_refresh_token_is_noop_and_untouched() -> TestResult {
+  let (_redis, mut conn, store) = test_redis().await?;
+  let service = service()?;
+  let owner = unique("owner");
+  let attacker = unique("attacker");
+  let minted = service
+    .mint(
+      &mut conn,
+      &store,
+      &mint_request(&owner, &unique("grant")),
+      now_unix(),
+    )
+    .await?;
+
+  let outcome = revoke_owned(
+    &mut conn,
+    &store,
+    &service,
+    &OwnedToken {
+      owner_client_id: &attacker,
+      token: &minted.refresh_token,
+      token_type_hint: Some(HINT_REFRESH),
+    },
+  )
+  .await?;
+  if outcome != RevokeOutcome::NoOp {
+    return Err(TestError::Unexpected(
+      "a foreign refresh token must be a no-op".to_owned(),
+    ));
+  }
+  if service
+    .find_refresh(&mut conn, &store, &minted.refresh_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "a foreign client must never revoke another client's refresh token".to_owned(),
+    ));
+  }
+  if service
+    .find_access(&mut conn, &store, &minted.access_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "the sibling access token must also survive a foreign attempt".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 26 - 4
src/protocol/token/dispatch.rs

@@ -3,8 +3,8 @@
 //! Parses the form once, authenticates the client through the shared
 //! [`client_auth`] dispatch (or delegates to the grants that authenticate
 //! themselves), verifies the fresh sender proof, dispatches on `grant_type`,
-//! and shapes the RFC 6749 §5.1 JSON response. Unknown grant types fail
-//! closed with `unsupported_grant_type`.
+//! and shapes the RFC 6749 §5.1 JSON response. A missing `grant_type` is
+//! `invalid_request`; an unknown one fails closed with `unsupported_grant_type`.
 //!
 //! The per-grant branches live in [`grants`]; this module owns the HTTP
 //! contract, the request-scoped handles, and the shared authentication and
@@ -13,7 +13,7 @@
 mod grants;
 
 use axum::Json;
-use axum::extract::{Extension, State};
+use axum::extract::{Extension, State, rejection::FormRejection};
 use axum::http::{HeaderMap, HeaderValue, header};
 use axum::response::{IntoResponse, Response};
 use secrecy::SecretString;
@@ -292,8 +292,12 @@ pub async fn handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  form: axum::Form<TokenForm>,
+  form: Result<axum::Form<TokenForm>, FormRejection>,
 ) -> Response {
+  let form = match form {
+    Ok(axum::Form(form)) => form,
+    Err(rejection) => return form_rejection_response(rejection),
+  };
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let now_unix = state.clock.now().unix_timestamp();
@@ -312,6 +316,24 @@ pub async fn handler(
   }
 }
 
+/// Map a form-extractor rejection onto the documented error model.
+///
+/// A deserialization failure is the transport shape of RFC 6749 §5.2
+/// "includes a parameter more than once", so it renders the documented
+/// `invalid_request` JSON error; non-form content types and body-read
+/// failures keep the extractor's own status and body.
+fn form_rejection_response(rejection: FormRejection) -> Response {
+  match rejection {
+    FormRejection::FailedToDeserializeForm(_) | FormRejection::FailedToDeserializeFormBody(_) => {
+      AppError::invalid_request(
+        "the request body is malformed or includes a parameter more than once",
+      )
+      .into_response()
+    }
+    other => other.into_response(),
+  }
+}
+
 /// Shape a success response: JSON plus the RFC 6749 §5.1 no-store headers.
 fn token_response(body: TokenResponseBody) -> Response {
   let mut response = Json(body).into_response();

+ 14 - 2
src/protocol/token/dispatch/grants.rs

@@ -29,9 +29,21 @@ use crate::{
   },
 };
 
-/// Dispatch on the parsed `grant_type`; unknown values fail closed.
+/// Dispatch on the parsed `grant_type`.
+///
+/// A missing or empty member is the RFC 6749 §5.2 `invalid_request` (the
+/// request is missing a required parameter); an unknown value fails closed
+/// with `unsupported_grant_type`.
 pub(super) async fn dispatch_grant(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
-  match d.form.grant_type.as_deref().unwrap_or("") {
+  let Some(grant_type) = d
+    .form
+    .grant_type
+    .as_deref()
+    .filter(|value| !value.is_empty())
+  else {
+    return Err(AppError::invalid_request("grant_type is required").into());
+  };
+  match grant_type {
     authorization_code::GRANT_TYPE_AUTHORIZATION_CODE => authorization_code(d).await,
     refresh_token::GRANT_TYPE_REFRESH_TOKEN => refresh(d).await,
     client_credentials::GRANT_TYPE_CLIENT_CREDENTIALS => client_credentials(d).await,

+ 222 - 0
src/protocol/token/grants/device_code/tests/backoff.rs

@@ -1,5 +1,7 @@
 //! `test(grant_device)` pacing: `slow_down` inside the interval, denial.
 
+use axum::http::StatusCode;
+
 use super::super::DevicePollError;
 use super::flows::{authorize, decide, now_unix, poll_once, public_inputs};
 use super::{TestError, TestResult, harness, unique};
@@ -160,3 +162,223 @@ async fn test_grant_device_denied_is_access_denied() -> TestResult {
     )),
   }
 }
+
+/// RFC 8628 §3.5: polling exactly `interval` seconds after the previous
+/// attempt — and just past that boundary — is `authorization_pending`, never
+/// a spurious `slow_down`; `slow_down` resumes only inside the window.
+#[tokio::test]
+async fn test_grant_device_interval_boundary_not_slow_down() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("public");
+  setup::insert_client(
+    &h.pool,
+    &h.config,
+    &client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read",
+  )
+  .await?;
+  let t0 = now_unix();
+  let key = keys::fresh_key()?;
+  let authorize_htu = keys::device_htu(&h.config);
+  let poll_htu = keys::token_htu(&h.config);
+  let interval = DEVICE_INTERVAL_SECS as i64;
+
+  let authorize_proof = keys::mint_proof(&key, &authorize_htu, &unique("jti"), t0)?;
+  let authorize_inputs = DeviceHttpInputs {
+    client_id: client_id.as_str(),
+    scope: None,
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: Some(authorize_proof.as_str()),
+    peer_certs: None,
+  };
+  let issued = authorize(&mut h, &authorize_inputs, t0)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
+
+  let first_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), t0)?;
+  match poll_once(
+    &mut h,
+    &public_inputs(&client_id, &issued.device_code, Some(&first_proof)),
+    t0,
+  )
+  .await
+  {
+    Err(DevicePollError::AuthorizationPending) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "first poll must pend, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "tokens must never issue before approval".to_owned(),
+      ));
+    }
+  }
+
+  // Exactly one interval after the stamped attempt: allowed (>= interval).
+  let boundary = t0 + interval;
+  let boundary_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), boundary)?;
+  match poll_once(
+    &mut h,
+    &public_inputs(&client_id, &issued.device_code, Some(&boundary_proof)),
+    boundary,
+  )
+  .await
+  {
+    Err(DevicePollError::AuthorizationPending) => {}
+    Err(DevicePollError::SlowDown { .. }) => {
+      return Err(TestError::Unexpected(
+        "a poll exactly at the interval boundary must not be slow_down".to_owned(),
+      ));
+    }
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "boundary poll must pend, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "tokens must never issue before approval".to_owned(),
+      ));
+    }
+  }
+
+  // Just past that boundary: still inside the contract.
+  let just_after = t0 + 2 * interval + 1;
+  let just_after_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), just_after)?;
+  match poll_once(
+    &mut h,
+    &public_inputs(&client_id, &issued.device_code, Some(&just_after_proof)),
+    just_after,
+  )
+  .await
+  {
+    Err(DevicePollError::AuthorizationPending) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "just-after-boundary poll must pend, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "tokens must never issue before approval".to_owned(),
+      ));
+    }
+  }
+
+  // One second later the pacing bites again: the marker advanced, so the
+  // boundary polls were really counted.
+  let too_soon = just_after + 1;
+  let too_soon_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), too_soon)?;
+  match poll_once(
+    &mut h,
+    &public_inputs(&client_id, &issued.device_code, Some(&too_soon_proof)),
+    too_soon,
+  )
+  .await
+  {
+    Err(DevicePollError::SlowDown { retry_after_secs }) => {
+      if retry_after_secs != (interval - 1) as u64 {
+        return Err(TestError::Unexpected(format!(
+          "slow_down must advertise the remaining window, got {retry_after_secs}"
+        )));
+      }
+      Ok(())
+    }
+    Err(other) => Err(TestError::Unexpected(format!(
+      "inside-window poll must be slow_down, got {}",
+      other.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "tokens must never issue before approval".to_owned(),
+    )),
+  }
+}
+
+/// RFC 8628 §3.5: after `access_denied` the code is terminal. A later poll
+/// stays `access_denied`, never `authorization_pending` and never a mint.
+#[tokio::test]
+async fn test_grant_device_denied_then_poll_stays_access_denied() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("public");
+  setup::insert_client(
+    &h.pool,
+    &h.config,
+    &client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read",
+  )
+  .await?;
+  let t0 = now_unix();
+  let key = keys::fresh_key()?;
+  let authorize_htu = keys::device_htu(&h.config);
+  let poll_htu = keys::token_htu(&h.config);
+  let subject = unique("subject");
+
+  let authorize_proof = keys::mint_proof(&key, &authorize_htu, &unique("jti"), t0)?;
+  let authorize_inputs = DeviceHttpInputs {
+    client_id: client_id.as_str(),
+    scope: None,
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: Some(authorize_proof.as_str()),
+    peer_certs: None,
+  };
+  let issued = authorize(&mut h, &authorize_inputs, t0)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
+  if !decide(&mut h, &issued.device_code, false, &subject).await? {
+    return Err(TestError::Unexpected("decide must apply".to_owned()));
+  }
+
+  for now in [t0 + 6, t0 + 12] {
+    let proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), now)?;
+    match poll_once(
+      &mut h,
+      &public_inputs(&client_id, &issued.device_code, Some(&proof)),
+      now,
+    )
+    .await
+    {
+      Err(error)
+        if error.code() == "access_denied" && error.status() == StatusCode::BAD_REQUEST => {}
+      Err(other) => {
+        return Err(TestError::Unexpected(format!(
+          "repeated denied poll must stay access_denied 400, got {}",
+          other.code()
+        )));
+      }
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "a denied request must never mint tokens".to_owned(),
+        ));
+      }
+    }
+  }
+
+  let device_hash = crate::protocol::device::hash_token(&h.config.keys, &issued.device_code)?;
+  let record = crate::store::device::poll(&mut h.conn, &h.store, &device_hash)
+    .await?
+    .ok_or_else(|| TestError::Unexpected("denied record must persist".to_owned()))?;
+  if record.status != crate::store::device::DeviceStatus::Denied
+    || record.subject != subject
+    || !record.grant_id.is_empty()
+  {
+    return Err(TestError::Unexpected(
+      "denial must stay terminal with no grant bound".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 100 - 0
src/protocol/token/grants/device_code/tests/matrix.rs

@@ -2,6 +2,8 @@
 
 use std::time::Duration;
 
+use axum::http::StatusCode;
+
 use super::super::{DevicePollError, DevicePollInputs, PollState, poll_device};
 use super::flows::{authorize, decide, now_unix, poll_once, public_inputs, secret};
 use super::{TestError, TestResult, harness, unique};
@@ -220,3 +222,101 @@ async fn test_grant_device_dpop_bound_confidential_requires_fresh_proof() -> Tes
   }
   Ok(())
 }
+
+/// RFC 6749 §5.2 and the error table in docs/OAUTH.md: a grant issued to
+/// another client is `invalid_grant`. The foreign poll must not consume the
+/// code, so the legitimate client still redeems it.
+#[tokio::test]
+async fn test_grant_device_cross_client_poll_is_invalid_grant() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let owner_id = unique("owner");
+  let intruder_id = unique("intruder");
+  setup::insert_client(
+    &h.pool,
+    &h.config,
+    &owner_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read",
+  )
+  .await?;
+  setup::insert_client(
+    &h.pool,
+    &h.config,
+    &intruder_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read",
+  )
+  .await?;
+  setup::enable_scopes(&h.pool, &["orders:read"]).await?;
+  let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
+  let t0 = now_unix();
+  let owner_key = keys::fresh_key()?;
+  let authorize_htu = keys::device_htu(&h.config);
+  let poll_htu = keys::token_htu(&h.config);
+
+  let authorize_proof = keys::mint_proof(&owner_key, &authorize_htu, &unique("jti"), t0)?;
+  let authorize_inputs = DeviceHttpInputs {
+    client_id: owner_id.as_str(),
+    scope: Some("orders:read"),
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: Some(authorize_proof.as_str()),
+    peer_certs: None,
+  };
+  let issued = authorize(&mut h, &authorize_inputs, t0)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
+  if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
+    return Err(TestError::Unexpected("decide must apply".to_owned()));
+  }
+
+  let t1 = t0 + 6;
+  let intruder_key = keys::fresh_key()?;
+  let intruder_proof = keys::mint_proof(&intruder_key, &poll_htu, &unique("jti"), t1)?;
+  match poll_once(
+    &mut h,
+    &public_inputs(&intruder_id, &issued.device_code, Some(&intruder_proof)),
+    t1,
+  )
+  .await
+  {
+    Err(DevicePollError::Protocol(error))
+      if error.code() == OAuthErrorCode::InvalidGrant
+        && error.status() == StatusCode::BAD_REQUEST => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "cross-client poll must be invalid_grant 400, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a foreign client must never mint a token".to_owned(),
+      ));
+    }
+  }
+
+  // The rejected poll left the grant untouched: the owner still redeems it.
+  let owner_proof = keys::mint_proof(&owner_key, &poll_htu, &unique("jti"), t1)?;
+  let tokens = poll_once(
+    &mut h,
+    &public_inputs(&owner_id, &issued.device_code, Some(&owner_proof)),
+    t1,
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("owner redemption rejected: {}", error.code())))?;
+  if tokens.token_type != "DPoP"
+    || tokens.access_token.is_empty()
+    || tokens.refresh_token.is_empty()
+    || tokens.scope != "orders:read"
+  {
+    return Err(TestError::Unexpected(
+      "the owner must still redeem the untouched grant".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 164 - 1
src/protocol/token/grants/device_code/tests/poll.rs

@@ -1,11 +1,16 @@
 //! `test(grant_device)` approval path: pending, then bound tokens once.
 
-use super::super::DevicePollError;
+use std::time::Duration;
+
+use axum::http::StatusCode;
+
+use super::super::{DevicePollError, PollState, poll_device};
 use super::flows::{authorize, decide, now_unix, poll_once, public_inputs};
 use super::{TestError, TestResult, harness, unique};
 use super::{keys, setup};
 use crate::{
   db::TokenEndpointAuthMethod,
+  error::OAuthErrorCode,
   jose::jwk::thumbprint,
   protocol::{
     device::{DEVICE_INTERVAL_SECS, DeviceHttpInputs},
@@ -142,3 +147,161 @@ async fn test_grant_device_pending_then_approved_issues_bound_tokens() -> TestRe
     )),
   }
 }
+
+/// RFC 8628 §3.5: an approved device code redeems exactly once. Two polls
+/// issued concurrently must mint exactly one token pair; the loser observes
+/// the terminal `expired_token` (or `invalid_grant`), never a second pair.
+///
+/// The interval is zeroed so the test isolates the atomic consume from
+/// §3.5 pacing, which the boundary suite pins separately.
+#[tokio::test]
+async fn test_grant_device_concurrent_poll_mints_once() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = unique("public");
+  setup::insert_client(
+    &h.pool,
+    &h.config,
+    &client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read",
+  )
+  .await?;
+  setup::enable_scopes(&h.pool, &["orders:read"]).await?;
+  let user_id = setup::entitled_user(&h.pool, &h.config, &["orders:read"]).await?;
+  let t0 = now_unix();
+  let key = keys::fresh_key()?;
+  let authorize_htu = keys::device_htu(&h.config);
+  let poll_htu = keys::token_htu(&h.config);
+
+  let authorize_proof = keys::mint_proof(&key, &authorize_htu, &unique("jti"), t0)?;
+  let authorize_inputs = DeviceHttpInputs {
+    client_id: client_id.as_str(),
+    scope: Some("orders:read"),
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: Some(authorize_proof.as_str()),
+    peer_certs: None,
+  };
+  let issued = authorize(&mut h, &authorize_inputs, t0)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
+  if !decide(&mut h, &issued.device_code, true, &user_id.to_string()).await? {
+    return Err(TestError::Unexpected("decide must apply".to_owned()));
+  }
+
+  let t1 = t0 + DEVICE_INTERVAL_SECS as i64 + 1;
+  let left_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), t1)?;
+  let right_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), t1)?;
+  let left_inputs = public_inputs(&client_id, &issued.device_code, Some(&left_proof));
+  let right_inputs = public_inputs(&client_id, &issued.device_code, Some(&right_proof));
+
+  // Separate handles to the same multiplexed connection so both polls run to
+  // completion concurrently.
+  let mut left_conn = h.conn.clone();
+  let mut right_conn = h.conn.clone();
+  let mut left_state = PollState {
+    pool: &h.pool,
+    conn: &mut left_conn,
+    store: &h.store,
+    keys: &h.config.keys,
+    issuer: &h.config.issuer,
+    device_ttl: h.config.ttl.device,
+    interval: Duration::ZERO,
+    ttl: &h.config.ttl,
+    now_unix: t1,
+  };
+  let mut right_state = PollState {
+    pool: &h.pool,
+    conn: &mut right_conn,
+    store: &h.store,
+    keys: &h.config.keys,
+    issuer: &h.config.issuer,
+    device_ttl: h.config.ttl.device,
+    interval: Duration::ZERO,
+    ttl: &h.config.ttl,
+    now_unix: t1,
+  };
+  let (left, right) = tokio::join!(
+    poll_device(&mut left_state, &left_inputs),
+    poll_device(&mut right_state, &right_inputs)
+  );
+
+  let (winner, loser) = match (left, right) {
+    (Ok(tokens), Err(error)) | (Err(error), Ok(tokens)) => (tokens, error),
+    (Ok(_), Ok(_)) => {
+      return Err(TestError::Unexpected(
+        "two racing polls must not both mint".to_owned(),
+      ));
+    }
+    (Err(left_error), Err(right_error)) => {
+      return Err(TestError::Unexpected(format!(
+        "racing polls must mint exactly one pair: {} / {}",
+        left_error.code(),
+        right_error.code()
+      )));
+    }
+  };
+  match &loser {
+    DevicePollError::ExpiredToken => {}
+    DevicePollError::Protocol(error) if error.code() == OAuthErrorCode::InvalidGrant => {}
+    other => {
+      return Err(TestError::Unexpected(format!(
+        "losing poll must be expired_token or invalid_grant, got {}",
+        other.code()
+      )));
+    }
+  }
+  if loser.status() != StatusCode::BAD_REQUEST {
+    return Err(TestError::Unexpected(
+      "losing poll must answer 400".to_owned(),
+    ));
+  }
+
+  // Exactly the winner's pair resolves; nothing else was minted.
+  let service = TokenService::new(&h.config.ttl, &h.config.keys)?;
+  if service
+    .find_access(&mut h.conn, &h.store, &winner.access_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "winner access token must resolve".to_owned(),
+    ));
+  }
+  if service
+    .find_refresh(&mut h.conn, &h.store, &winner.refresh_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "winner refresh token must resolve".to_owned(),
+    ));
+  }
+  if winner.token_type != "DPoP" || winner.scope != "orders:read" {
+    return Err(TestError::Unexpected(
+      "winner pair must be DPoP-bound in the granted scope".to_owned(),
+    ));
+  }
+
+  // The code is consumed: a follow-up poll is terminal, never a re-mint.
+  let replay_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), t1 + 6)?;
+  match poll_once(
+    &mut h,
+    &public_inputs(&client_id, &issued.device_code, Some(&replay_proof)),
+    t1 + 6,
+  )
+  .await
+  {
+    Err(DevicePollError::ExpiredToken) => Ok(()),
+    Err(other) => Err(TestError::Unexpected(format!(
+      "consumed code must be expired_token, got {}",
+      other.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "a consumed code must never mint again".to_owned(),
+    )),
+  }
+}

+ 4 - 0
src/store/mod.rs

@@ -565,6 +565,10 @@ pub(crate) static SCRIPT_ROTATE_REFRESH: LazyLock<Script> = LazyLock::new(|| {
     redis.call('HSET', unpack(aargs))
     redis.call('PEXPIRE', KEYS[3], ARGV[5])
     redis.call('SADD', KEYS[4], ARGV[8], ARGV[9])
+    local set_pttl = redis.call('PTTL', KEYS[4])
+    if set_pttl < 0 or set_pttl < tonumber(ARGV[3]) then
+      redis.call('PEXPIRE', KEYS[4], ARGV[3])
+    end
     local pttl = redis.call('PTTL', KEYS[1])
     local tomb_ttl = tonumber(ARGV[10])
     if pttl > 0 and pttl < tomb_ttl then

+ 337 - 2
src/store/tests_store.rs

@@ -17,10 +17,10 @@ use testcontainers::{
 
 use super::{
   ErrorClass, OpClass, Store, StoreError, classify_redis, codes, dcr, device, durability_barrier,
-  grants, may_retry_op, par, ratelimit, retry_op, sessions, tokens,
+  grants, may_retry_op, par, pending, ratelimit, retry_op, sessions, sha256_hex, tokens,
 };
 use crate::{
-  config::{ConfigError, DurabilityMode, RedisConfig, redis::RawRedisConfig},
+  config::{ConfigError, DurabilityMode, RedisConfig, TtlConfig, redis::RawRedisConfig},
   state::RedisConn,
   test_guard::ContainerGuard,
 };
@@ -154,6 +154,28 @@ fn rotation_input(
   }
 }
 
+/// Remaining TTL of a key in milliseconds (`-1` persistent, `-2` absent).
+async fn pttl_ms(conn: &mut RedisConn, key: &str) -> Result<i64, TestError> {
+  let pttl: i64 = redis::cmd("PTTL").arg(key).query_async(conn).await?;
+  Ok(pttl)
+}
+
+/// Assert a key carries a live TTL inside the design window `(0, cap]`.
+///
+/// `cap` comes from the configured/design lifetime, never from the observed
+/// value. A non-positive TTL means the key is persistent or absent (a defect:
+/// the artifact would outlive its intended window); the upper bound can only
+/// drift downward while the test runs, so a slow run cannot flake it.
+fn assert_ttl_within(pttl: i64, cap: Duration) -> TestResult {
+  if pttl <= 0 || pttl > cap.as_millis() as i64 {
+    return Err(TestError::Unexpected(format!(
+      "PTTL {pttl} ms must be within (0, {} ms]",
+      cap.as_millis()
+    )));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn store_code_consume_exactly_once() -> TestResult {
   let (_container, url) = redis_container(Vec::new()).await?;
@@ -973,6 +995,319 @@ async fn store_device_par_and_dcr_round_trip() -> TestResult {
   Ok(())
 }
 
+/// docs/REDIS.md: `oauth:{state}:code:<hash>` carries the authorization-code
+/// lifetime, capped at [`crate::protocol::code::CODE_TTL_CAP`] (60 s).
+#[tokio::test]
+async fn store_code_ttl_within_configured_window() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let ttl = TtlConfig::default();
+  let hash = unique("code");
+
+  codes::issue(
+    &mut conn,
+    &store,
+    &hash,
+    &code_record(&unique("client"), &unique("grant")),
+    ttl.code,
+  )
+  .await?;
+  let cap = ttl.code.min(crate::protocol::code::CODE_TTL_CAP);
+  assert_ttl_within(pttl_ms(&mut conn, &store.code_key(&hash)).await?, cap)
+}
+
+/// docs/REDIS.md: the PAR handle lives under `ttl.par` (90 s) and both the
+/// device-code record and its user-code mapping under `ttl.device` (600 s).
+#[tokio::test]
+async fn store_par_device_and_user_code_ttl_within_configured_windows() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let ttl = TtlConfig::default();
+  let client = unique("client");
+
+  let request_id = unique("par");
+  par::put(
+    &mut conn,
+    &store,
+    &request_id,
+    &par::ParRecord {
+      client_id: client.clone(),
+      request: "{\"response_type\":\"code\"}".to_owned(),
+    },
+    ttl.par,
+  )
+  .await?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.par_key(&request_id)).await?,
+    ttl.par,
+  )?;
+
+  let device_hash = unique("device");
+  let user_code = unique("user-code");
+  device::put(
+    &mut conn,
+    &store,
+    &device_hash,
+    &user_code,
+    &device::DeviceRecord {
+      client_id: client,
+      scope: "orders:read".to_owned(),
+      status: device::DeviceStatus::Pending,
+      subject: String::new(),
+      grant_id: String::new(),
+    },
+    ttl.device,
+  )
+  .await?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.device_key(&device_hash)).await?,
+    ttl.device,
+  )?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.user_code_key(&user_code)).await?,
+    ttl.device,
+  )
+}
+
+/// docs/REDIS.md: the DCR initial-access-token record lives under the CLI
+/// `--ttl`, defaulting to the configured `ttl.dcr_initial_access` (24 h).
+#[tokio::test]
+async fn store_dcr_initial_access_ttl_within_configured_window() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let ttl = TtlConfig::default();
+  let iat = unique("iat");
+
+  dcr::issue(
+    &mut conn,
+    &store,
+    &iat,
+    &dcr::InitialAccessRecord {
+      uses_remaining: 2,
+      label: "ops-token".to_owned(),
+    },
+    ttl.dcr_initial_access,
+  )
+  .await?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.dcr_iat_key(&iat)).await?,
+    ttl.dcr_initial_access,
+  )
+}
+
+/// docs/REDIS.md: the rotation idempotency mapping is bounded by
+/// `min(900 s, refresh TTL)`, the old refresh record becomes a tombstone with
+/// the refresh lifetime, and the per-grant token index lives with the grant.
+#[tokio::test]
+async fn store_rotation_grace_tombstone_and_grant_index_ttl() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let grant = unique("grant");
+  let client = unique("client");
+  let old = unique("old-refresh");
+  let new_refresh = unique("new-refresh");
+  let new_access = unique("new-access");
+  let pepper = b"store-test-pepper";
+  let refresh_ttl = Duration::from_secs(3600);
+  let access_ttl = Duration::from_secs(300);
+  let rotation_grace = Duration::from_secs(900);
+
+  grants::create(
+    &mut conn,
+    &store,
+    &grants::GrantRecord {
+      grant_id: grant.clone(),
+      client_id: client.clone(),
+      subject: "user-1".to_owned(),
+      scope: "orders:read".to_owned(),
+    },
+    refresh_ttl,
+  )
+  .await?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.grant_key(&grant)).await?,
+    refresh_ttl,
+  )?;
+
+  // The index is born with the access member, then extended by the refresh
+  // member: it must outlive the short access TTL and stay within the grant's.
+  tokens::put_access(
+    &mut conn,
+    &store,
+    &unique("access-seed"),
+    &access_record(&client, &grant),
+    access_ttl,
+  )
+  .await?;
+  tokens::put_refresh(
+    &mut conn,
+    &store,
+    &old,
+    &refresh_record(&client, &grant),
+    refresh_ttl,
+  )
+  .await?;
+  let index_key = store.grant_tokens_key(&grant);
+  let index_ttl = pttl_ms(&mut conn, &index_key).await?;
+  if index_ttl <= access_ttl.as_millis() as i64 {
+    return Err(TestError::Unexpected(
+      "grant index must outlive the access-token TTL".to_owned(),
+    ));
+  }
+  assert_ttl_within(index_ttl, refresh_ttl)?;
+
+  // Simulate the index having expired: the rotation script must re-arm it
+  // rather than recreate it persistent.
+  let _: () = redis::cmd("DEL")
+    .arg(&index_key)
+    .query_async(&mut conn)
+    .await?;
+  let outcome = tokens::rotate(
+    &mut conn,
+    &store,
+    pepper,
+    &rotation_input(&old, &client, &new_refresh, &new_access, &grant, None),
+  )
+  .await?;
+  if !matches!(outcome, tokens::RotateOutcome::Rotated { .. }) {
+    return Err(TestError::Unexpected("rotation must apply".to_owned()));
+  }
+  assert_ttl_within(pttl_ms(&mut conn, &index_key).await?, refresh_ttl)?;
+
+  let request_id = tokens::derive_rotation_id(pepper, &old)?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.rotation_map_key(&request_id)).await?,
+    rotation_grace.min(refresh_ttl),
+  )?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.refresh_key(&old)).await?,
+    refresh_ttl,
+  )
+}
+
+/// docs/REDIS.md: `oauth:{state}:session:<id>` lives under `ttl.session`
+/// (12 h); a presession is the same record before login binds a subject.
+#[tokio::test]
+async fn store_session_and_presession_ttl_within_configured_window() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let session_ttl = TtlConfig::default().session;
+
+  let session_id = unique("session");
+  sessions::put(
+    &mut conn,
+    &store,
+    &session_id,
+    &sessions::SessionRecord {
+      subject: "user-1".to_owned(),
+      mfa_verified: true,
+      restricted: false,
+    },
+    session_ttl,
+  )
+  .await?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.session_key(&session_id)).await?,
+    session_ttl,
+  )?;
+
+  let presession_id = unique("presession");
+  sessions::put(
+    &mut conn,
+    &store,
+    &presession_id,
+    &sessions::SessionRecord {
+      subject: String::new(),
+      mfa_verified: false,
+      restricted: false,
+    },
+    session_ttl,
+  )
+  .await?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.session_key(&presession_id)).await?,
+    session_ttl,
+  )
+}
+
+/// docs/REDIS.md: `oauth:{state}:pending:<id>` is short, request-scoped
+/// (`routes::PENDING_TTL`, 300 s).
+#[tokio::test]
+async fn store_pending_auth_ttl_within_request_window() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let request_id = unique("pending");
+
+  pending::put(
+    &mut conn,
+    &store,
+    &request_id,
+    &pending::PendingAuth {
+      client_id: unique("client"),
+      redirect_uri: "https://client.example.test/cb".to_owned(),
+      scope: "orders:read".to_owned(),
+      code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_owned(),
+      state: Some("state-xyz".to_owned()),
+      dpop_jkt: None,
+      iss: "https://as.example.test".to_owned(),
+      post_logout_uri: None,
+    },
+    crate::routes::PENDING_TTL,
+  )
+  .await?;
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.token_key("pending", &request_id)).await?,
+    crate::routes::PENDING_TTL,
+  )
+}
+
+/// docs/REDIS.md: the failed-login counter lives under the lockout window
+/// (`ui::login::LOCKOUT_WINDOW`, 15 min) and keys a hash of the account label.
+#[tokio::test]
+async fn store_login_failure_counter_ttl_within_lockout_window() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let window = crate::ui::login::LOCKOUT_WINDOW;
+  let account = unique("alice@example.test");
+
+  sessions::record_failed_login(&mut conn, &store, &account, window).await?;
+  let key = store.login_fail_key(&sha256_hex(account.as_bytes()));
+  assert_ttl_within(pttl_ms(&mut conn, &key).await?, window)
+}
+
+/// docs/REDIS.md: the DPoP and `private_key_jwt` replay markers are bounded by
+/// the proof window ([`crate::jose::dpop::DPOP_JTI_TTL`] and the assertion
+/// module's `JTI_TTL`, both 300 s).
+#[tokio::test]
+async fn store_replay_marker_ttl_within_proof_window() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+
+  let dpop_ttl = crate::jose::dpop::DPOP_JTI_TTL;
+  let jti = unique("jti");
+  if !dcr::claim_dpop_jti(&mut conn, &store, &jti, dpop_ttl).await? {
+    return Err(TestError::Unexpected(
+      "first DPoP claim must win".to_owned(),
+    ));
+  }
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.dpop_jti_key(&jti)).await?,
+    dpop_ttl,
+  )?;
+
+  let assertion_ttl = Duration::from_secs(300);
+  let assertion = unique("assertion");
+  if !dcr::claim_client_assertion_jti(&mut conn, &store, &assertion, assertion_ttl).await? {
+    return Err(TestError::Unexpected(
+      "first assertion claim must win".to_owned(),
+    ));
+  }
+  assert_ttl_within(
+    pttl_ms(&mut conn, &store.client_assertion_jti_key(&assertion)).await?,
+    assertion_ttl,
+  )
+}
+
 #[test]
 fn store_keys_share_hash_tag_and_keyspaces_split() -> TestResult {
   let store = Store::from_parts(

+ 26 - 0
src/store/tokens.rs

@@ -492,6 +492,32 @@ async fn put_member(
     .query_async(conn)
     .await
     .map_err(StoreError::Redis)?;
+  ensure_set_ttl(conn, set_key, ttl).await
+}
+
+/// Keep the per-grant token set alive at least as long as its longest-lived
+/// member: arm an expiry when the set is persistent, extend it when the member
+/// outlives the current one, never shorten it. The set "lives with the grant"
+/// (docs/REDIS.md) instead of leaking member hashes forever.
+///
+/// Both steps are single atomic commands, so a concurrent writer can never
+/// shorten a longer expiry: `NX` arms only a persistent set (a no-op once one
+/// is set), then `GT` extends only a shorter one.
+async fn ensure_set_ttl(
+  conn: &mut RedisConn,
+  set_key: &str,
+  ttl: Duration,
+) -> Result<(), StoreError> {
+  let ttl_ms = ttl.as_millis() as u64;
+  for mode in ["NX", "GT"] {
+    let _: () = redis::cmd("PEXPIRE")
+      .arg(set_key)
+      .arg(ttl_ms)
+      .arg(mode)
+      .query_async(conn)
+      .await
+      .map_err(StoreError::Redis)?;
+  }
   Ok(())
 }
 

+ 544 - 0
tests/e2e_flow.rs

@@ -0,0 +1,544 @@
+//! `test(e2e_flow)` full-browser authorization-code flow over the live router.
+//!
+//! The existing e2e suites stop at `303 /login?request_id=` (or mint codes
+//! directly through [`d_oauth::protocol::code::CodeIssuer`]); no test drives
+//! the complete browser flow. These tests do: `GET /authorize` (RFC 6749
+//! §4.1.1 + OAuth 2.1 draft §4.1.3) → `GET /login` → `POST /login` (password +
+//! TOTP) → `GET /consent` → `POST /consent` → code on the registered redirect
+//! (RFC 9700 §4.5.3, `code` + `state` + RFC 9207 `iss`) → `POST /token` with
+//! the PKCE verifier (RFC 7636 §4.5) → tokens.
+//!
+//! A public client is used so the flow needs no PAR/JAR (a confidential
+//! client with only plain query params is rejected with `invalid_request`).
+//! Public clients are DPoP-bound end to end: the SAME [`DpopKey`] proves
+//! `GET /authorize` (its `jkt` is stashed in the pending-auth record) and
+//! `POST /token` (the redemption checks the proof `jkt` against the code's
+//! `cnf` binding), so one key spans the whole flow.
+
+#[allow(dead_code)]
+#[path = "e2e/harness.rs"]
+mod harness;
+
+#[path = "e2e/dpop.rs"]
+mod dpop;
+
+use std::time::Duration;
+
+use d_oauth::db::{
+  TokenEndpointAuthMethod,
+  user_repo::{self, NewUser},
+  user_scope_repo,
+};
+use dpop::DpopKey;
+use harness::{
+  TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
+  seed_scope,
+};
+
+/// Fixture public client (no secret: DPoP is its sender constraint).
+const CLIENT_ID: &str = "e2e-flow-public";
+/// Registered loopback redirect (RFC 8252 loopback exception applies).
+const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
+/// Catalog scope exercised by the flow.
+const SCOPE: &str = "read";
+/// `state` echoed from authorize to the code redirect (unreserved chars only).
+const STATE: &str = "e2e-flow-state";
+/// Session cookie name (production `src/ui/login.rs`: `SESSION_COOKIE`).
+const SESSION_COOKIE: &str = "session_id";
+/// Uniform credential-rejection page text (`INVALID_CREDENTIALS`).
+const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
+
+/// One browser driving the flow: live world, PKCE pair, DPoP key, login
+/// session material, and the provisioned user's credentials.
+struct Browser {
+  world: World,
+  dpop: DpopKey,
+  verifier: String,
+  request_id: String,
+  presession: String,
+  login_csrf: String,
+  email: String,
+  password: String,
+  totp_secret: String,
+}
+
+/// Seed the world (public client + scope + redirect) and one TOTP-enrolled
+/// user entitled to the scope; drive `GET /authorize` → `GET /login`.
+async fn begin_browser(email: &str, password: &str) -> Result<Browser, TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    CLIENT_ID,
+    None,
+    &["authorization_code", "refresh_token"],
+    SCOPE,
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  seed_scope(&world.pool, SCOPE).await?;
+  attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?;
+  let totp_secret = seed_user(&world, email, password).await?;
+
+  let (challenge, verifier) = oauth2_pkce();
+  let dpop = DpopKey::generate()?;
+  let proof = dpop.proof("GET", &format!("{}/authorize", world.state.config.issuer))?;
+  let (status, headers, body) = world
+    .get(
+      &format!(
+        "/authorize?client_id={CLIENT_ID}&redirect_uri={}&response_type=code&scope={SCOPE}\
+         &state={STATE}&code_challenge={challenge}&code_challenge_method=S256",
+        encoded(REDIRECT_URI)
+      ),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("authorize must 303 to login: {status} {body}"),
+  )?;
+  let request_id = query_value(&location_of(&headers)?, "request_id")?;
+  // The authorize layer stashes the verified DPoP thumbprint in the
+  // pending-auth record the later code issuance binds (RFC 9700 §4.5.3).
+  let mut conn = world.state.redis.clone();
+  let record = d_oauth::store::pending::get(&mut conn, &world.store, &request_id)
+    .await
+    .map_err(|error| fail(format!("pending read failed: {error}")))?
+    .ok_or_else(|| fail("pending record must exist"))?;
+  let expected = d_oauth::jose::jwk::thumbprint(&dpop.jwk())
+    .map_err(|error| fail(format!("jwk thumbprint: {error}")))?;
+  ensure(
+    record.dpop_jkt.as_deref() == Some(expected.as_str()),
+    "pending record must carry the verified DPoP thumbprint",
+  )?;
+
+  let (status, headers, body) = world
+    .get(&format!("/login?request_id={request_id}"), &[])
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("login page must render: {status} {body}"),
+  )?;
+  let presession = cookie_value(&headers, SESSION_COOKIE)?;
+  let login_csrf = hidden_input(&body, "csrf")?;
+  Ok(Browser {
+    world,
+    dpop,
+    verifier,
+    request_id,
+    presession,
+    login_csrf,
+    email: email.to_owned(),
+    password: password.to_owned(),
+    totp_secret,
+  })
+}
+
+/// Provision one enabled user (known password + TOTP secret, entitled to
+/// `SCOPE`) through the REAL repository path, using the server's own Argon2
+/// parameters so verification matches production exactly.
+async fn seed_user(world: &World, email: &str, password: &str) -> Result<String, TestError> {
+  let raw = &world.state.config.argon2;
+  let params =
+    d_oauth::crypto::secret::Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32)
+      .map_err(|error| fail(format!("argon2 params: {error}")))?;
+  let hash = d_oauth::crypto::secret::hash_secret(password, &params)
+    .map_err(|error| fail(format!("hash password: {error}")))?;
+  let created = user_repo::create_user(
+    &world.pool,
+    &world.state.keys,
+    world.state.config.pii_dual_write,
+    &NewUser {
+      email: email.to_owned(),
+      password_hash: hash,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  let secret = d_oauth::crypto::totp::generate_secret()
+    .map_err(|error| fail(format!("totp secret: {error}")))?;
+  let blob = d_oauth::crypto::totp::encrypt_secret(
+    &world.state.keys,
+    &secret,
+    !world.state.config.pii_dual_write,
+  )
+  .map_err(|error| fail(format!("totp seal: {error}")))?;
+  ensure(
+    user_repo::set_totp_secret(&world.pool, created.id, &blob).await?,
+    "totp enroll must update the user row",
+  )?;
+  user_scope_repo::entitle(&world.pool, created.id, SCOPE, None).await?;
+  Ok(secret)
+}
+
+/// Current TOTP code from the wall clock, computed the way the server
+/// verifies it (RFC 6238, ±1-step window). Steps back from the step edge so
+/// the code cannot roll over between generation and the `POST /login`.
+async fn totp_now(secret: &str) -> Result<String, TestError> {
+  let step = d_oauth::crypto::totp::TOTP_STEP_SECS;
+  let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
+  if now % step >= step - 2 {
+    tokio::time::sleep(Duration::from_secs(3)).await;
+  }
+  let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
+  let verifier = d_oauth::crypto::totp::build_totp(secret)
+    .map_err(|error| fail(format!("totp verifier: {error}")))?;
+  let counter = now / step;
+  Ok(format!("{:06}", verifier.generate(counter * step)))
+}
+
+/// `POST /login` as the browser: presession cookie + form credentials.
+async fn post_login(
+  browser: &Browser,
+  password: &str,
+  code: &str,
+) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
+  let cookie = format!("{SESSION_COOKIE}={}", browser.presession);
+  browser
+    .world
+    .post_form(
+      "/login",
+      &[
+        ("email", browser.email.as_str()),
+        ("password", password),
+        ("code", code),
+        ("csrf", browser.login_csrf.as_str()),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await
+}
+
+/// `GET /consent` as the browser holding `session`.
+async fn get_consent(
+  browser: &Browser,
+  session: &str,
+) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
+  let cookie = format!("{SESSION_COOKIE}={session}");
+  browser
+    .world
+    .get("/consent", &[("cookie", cookie.as_str())])
+    .await
+}
+
+/// `POST /consent` with the bound `request_id` and the session form token.
+async fn post_consent(
+  browser: &Browser,
+  session: &str,
+  request_id: &str,
+  csrf: &str,
+  decision: &str,
+) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
+  let cookie = format!("{SESSION_COOKIE}={session}");
+  browser
+    .world
+    .post_form(
+      "/consent",
+      &[
+        ("request_id", request_id),
+        ("csrf", csrf),
+        ("decision", decision),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await
+}
+
+/// Happy path: the full browser flow issues a code and redeems it for
+/// DPoP-bound tokens; the code is single-use (RFC 6749 §4.1.2: a second
+/// redemption is `invalid_grant`).
+#[tokio::test]
+async fn e2e_flow_browser_authorize_login_consent_token() -> Result<(), TestError> {
+  let browser = begin_browser("flow-happy@example.test", "correct-horse-28-happy-path").await?;
+  let code_2fa = totp_now(&browser.totp_secret).await?;
+  let (status, headers, _) = post_login(&browser, &browser.password.clone(), &code_2fa).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("login must 303 to consent: {status}"),
+  )?;
+  let location = location_of(&headers)?;
+  ensure(
+    location == "/consent",
+    format!("login must land on consent, got {location}"),
+  )?;
+  let session = cookie_value(&headers, SESSION_COOKIE)?;
+  ensure(
+    session != browser.presession,
+    "login must rotate the session id",
+  )?;
+
+  let (status, _, body) = get_consent(&browser, &session).await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("consent must render: {status} {body}"),
+  )?;
+  let bound = hidden_input(&body, "request_id")?;
+  ensure(
+    bound == browser.request_id,
+    "consent must serve the bound authorization request",
+  )?;
+  let consent_csrf = hidden_input(&body, "csrf")?;
+
+  let (status, headers, _) =
+    post_consent(&browser, &session, &bound, &consent_csrf, "approve").await?;
+  ensure(
+    status == reqwest::StatusCode::FOUND,
+    format!("consent approval must 302 to the redirect: {status}"),
+  )?;
+  let location = location_of(&headers)?;
+  ensure(
+    location.starts_with(REDIRECT_URI),
+    format!("code must return to the registered redirect, got {location}"),
+  )?;
+  let code = query_value(&location, "code")?;
+  ensure(
+    query_value(&location, "state")? == STATE,
+    "code redirect must echo state",
+  )?;
+  ensure(
+    has_query_key(&location, "iss"),
+    "code redirect must carry iss (RFC 9207)",
+  )?;
+
+  // Authorization-code redemption (RFC 7636 §4.5): the same DPoP key that
+  // proved `/authorize` proves `/token`, binding the minted token.
+  let proof = browser.dpop.proof(
+    "POST",
+    &format!("{}/token", browser.world.state.config.issuer),
+  )?;
+  let (status, _, body) = browser
+    .world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "authorization_code"),
+        ("code", code.as_str()),
+        ("redirect_uri", REDIRECT_URI),
+        ("client_id", CLIENT_ID),
+        ("code_verifier", browser.verifier.as_str()),
+      ],
+      None,
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("code redemption must mint: {status} {body}"),
+  )?;
+  let tokens = json_body(&body)?;
+  ensure(
+    tokens["token_type"] == "DPoP",
+    format!("public issuance must be DPoP-bound, got {tokens}"),
+  )?;
+  ensure(
+    tokens["access_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "redemption must return an access token",
+  )?;
+  ensure(
+    tokens["refresh_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "a registered refresh_token grant must return a refresh token",
+  )?;
+  ensure(
+    tokens["scope"] == SCOPE,
+    format!("tokens must carry the scope, got {tokens}"),
+  )?;
+
+  // The code is single-use: a second redemption fails closed.
+  let replay = browser.dpop.proof(
+    "POST",
+    &format!("{}/token", browser.world.state.config.issuer),
+  )?;
+  let (status, _, body) = browser
+    .world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "authorization_code"),
+        ("code", code.as_str()),
+        ("redirect_uri", REDIRECT_URI),
+        ("client_id", CLIENT_ID),
+        ("code_verifier", browser.verifier.as_str()),
+      ],
+      None,
+      &[("dpop", replay.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::BAD_REQUEST,
+    format!("code replay must be 400, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "invalid_grant",
+    "code replay must be invalid_grant",
+  )?;
+  Ok(())
+}
+
+/// A wrong password is rejected uniformly (401, no session upgrade): the
+/// presession never becomes an authenticated session, so `/consent` bounces
+/// back to `/login` and no code can ever issue for the flow.
+#[tokio::test]
+async fn e2e_flow_login_wrong_password_rejected() -> Result<(), TestError> {
+  let browser = begin_browser("flow-badpw@example.test", "correct-horse-28-right-one").await?;
+  let code_2fa = totp_now(&browser.totp_secret).await?;
+  let (status, headers, body) = post_login(&browser, "wrong-password-00000000", &code_2fa).await?;
+  ensure(
+    status == reqwest::StatusCode::UNAUTHORIZED,
+    format!("wrong password must be 401, got {status}: {body}"),
+  )?;
+  ensure(
+    body.contains(INVALID_CREDENTIALS),
+    "rejection must render the uniform message",
+  )?;
+  ensure(
+    cookie_value(&headers, SESSION_COOKIE).is_err(),
+    "rejection must not upgrade the session",
+  )?;
+  let (status, headers, _) = get_consent(&browser, &browser.presession).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("without a session consent must bounce to login: {status}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/login",
+    "the bounce target must be the login page",
+  )?;
+  Ok(())
+}
+
+/// Denying consent redirects to the registered redirect with
+/// `error=access_denied` + echoed `state` (RFC 6749 §4.1.2.1) and issues no
+/// code.
+#[tokio::test]
+async fn e2e_flow_consent_deny_redirects_access_denied() -> Result<(), TestError> {
+  let browser = begin_browser("flow-deny@example.test", "correct-horse-28-deny-case").await?;
+  let code_2fa = totp_now(&browser.totp_secret).await?;
+  let (status, headers, body) = post_login(&browser, &browser.password.clone(), &code_2fa).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("login must 303 to consent: {status} {body}"),
+  )?;
+  let session = cookie_value(&headers, SESSION_COOKIE)?;
+  let (status, _, body) = get_consent(&browser, &session).await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("consent must render: {status}"),
+  )?;
+  let bound = hidden_input(&body, "request_id")?;
+  let consent_csrf = hidden_input(&body, "csrf")?;
+
+  let (status, headers, _) =
+    post_consent(&browser, &session, &bound, &consent_csrf, "deny").await?;
+  ensure(
+    status == reqwest::StatusCode::FOUND,
+    format!("consent denial must 302 to the redirect: {status}"),
+  )?;
+  let location = location_of(&headers)?;
+  ensure(
+    location.starts_with(REDIRECT_URI),
+    format!("denial must return to the registered redirect, got {location}"),
+  )?;
+  ensure(
+    query_value(&location, "error")? == "access_denied",
+    "denial must carry error=access_denied",
+  )?;
+  ensure(
+    query_value(&location, "state")? == STATE,
+    "denial must echo state",
+  )?;
+  ensure(
+    !has_query_key(&location, "code"),
+    "denial must issue zero codes",
+  )?;
+  Ok(())
+}
+
+/// PKCE pair from the `oauth2` crate (baseline-flow material only).
+fn oauth2_pkce() -> (String, String) {
+  let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
+  (challenge.as_str().to_owned(), verifier.secret().to_owned())
+}
+
+/// Percent-encode a value for query embedding.
+fn encoded(value: &str) -> String {
+  let mut out = String::with_capacity(value.len());
+  for byte in value.bytes() {
+    if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
+      out.push(byte as char);
+    } else {
+      out.push_str(&format!("%{byte:02X}"));
+    }
+  }
+  out
+}
+
+/// Read the `location` header or fail the test.
+fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
+  headers
+    .get("location")
+    .and_then(|value| value.to_str().ok())
+    .map(str::to_owned)
+    .ok_or_else(|| fail("response lacks location header"))
+}
+
+/// Read a cookie value from the `set-cookie` headers or fail the test.
+fn cookie_value(headers: &reqwest::header::HeaderMap, name: &str) -> Result<String, TestError> {
+  let prefix = format!("{name}=");
+  let mut found = None;
+  for value in headers.get_all("set-cookie").iter() {
+    let text = value
+      .to_str()
+      .map_err(|_| fail("set-cookie is not visible ascii"))?;
+    if let Some(rest) = text.strip_prefix(prefix.as_str()) {
+      let end = rest.find(';').unwrap_or(rest.len());
+      found = Some(rest[..end].to_owned());
+    }
+  }
+  found.ok_or_else(|| fail(format!("response lacks {name} cookie")))
+}
+
+/// Read a hidden form field (`name=".." value=".."`) from a page or fail.
+fn hidden_input(body: &str, name: &str) -> Result<String, TestError> {
+  let needle = format!("name=\"{name}\" value=\"");
+  let start = body
+    .find(needle.as_str())
+    .ok_or_else(|| fail(format!("page lacks hidden {name}")))?;
+  let rest = &body[start + needle.len()..];
+  let end = rest
+    .find('"')
+    .ok_or_else(|| fail(format!("hidden {name} is unterminated")))?;
+  Ok(rest[..end].to_owned())
+}
+
+/// Read one query value from a redirect location or fail the test.
+fn query_value(location: &str, key: &str) -> Result<String, TestError> {
+  let query = location
+    .split_once('?')
+    .map(|pair| pair.1)
+    .ok_or_else(|| fail(format!("location carries no query: {location}")))?;
+  let prefix = format!("{key}=");
+  for pair in query.split('&') {
+    if let Some(value) = pair.strip_prefix(prefix.as_str()) {
+      return Ok(value.to_owned());
+    }
+  }
+  Err(fail(format!("location lacks {key}: {location}")))
+}
+
+/// Whether a redirect location carries a query key.
+fn has_query_key(location: &str, key: &str) -> bool {
+  let Some(query) = location.split_once('?').map(|pair| pair.1) else {
+    return false;
+  };
+  let prefix = format!("{key}=");
+  query
+    .split('&')
+    .any(|pair| pair.strip_prefix(prefix.as_str()).is_some())
+}

+ 402 - 1
tests/e2e_grants.rs

@@ -18,10 +18,12 @@ mod dpop;
 #[path = "e2e/clients.rs"]
 mod clients;
 
-use d_oauth::db::TokenEndpointAuthMethod;
+use d_oauth::db::{TokenEndpointAuthMethod, user_repo, user_scope_repo};
 use d_oauth::protocol::code::{CodeClientKind, CodeIssueRequest, CodeIssuer, IssuedCode};
+use d_oauth::protocol::device::hash_token;
 use d_oauth::protocol::scope::ScopeSet;
 use d_oauth::protocol::token::ClientKind;
+use d_oauth::store::device::{DeviceStatus, decide};
 use dpop::DpopKey;
 use harness::{
   SeedClientOpts, TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit,
@@ -401,6 +403,405 @@ async fn e2e_concurrent_rotation_fail_closed() -> Result<(), TestError> {
   Ok(())
 }
 
+/// Concurrent redemptions of one authorization code: exactly one exchanges,
+/// every other racer is `invalid_grant` — the code is consumed atomically, so
+/// only that one winner can create a token family.
+#[tokio::test]
+async fn e2e_concurrent_code_redemption_fail_closed() -> Result<(), TestError> {
+  let world = setup().await?;
+  let (challenge, verifier) = oauth2_pkce();
+  let issued = issue_conf_code(&world, CLIENT_ID, &challenge).await?;
+  let verifier = verifier.secret().to_owned();
+  let client = reqwest::Client::builder()
+    .redirect(reqwest::redirect::Policy::none())
+    .timeout(std::time::Duration::from_secs(60))
+    .build()?;
+  let mut set = tokio::task::JoinSet::new();
+  for _ in 0..2 {
+    let client = client.clone();
+    let base = world.base.clone();
+    let code = issued.code.clone();
+    let verifier = verifier.clone();
+    set.spawn(async move {
+      client
+        .post(format!("{base}/token"))
+        .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
+        .form(&[
+          ("grant_type", "authorization_code"),
+          ("code", code.as_str()),
+          ("redirect_uri", REDIRECT_URI),
+          ("client_id", CLIENT_ID),
+          ("code_verifier", verifier.as_str()),
+        ])
+        .send()
+        .await
+    });
+  }
+  let mut successes = 0;
+  let mut rejections = 0;
+  let mut unavailable = 0;
+  while let Some(outcome) = set.join_next().await {
+    let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
+    let status = response.status();
+    let body = response.text().await?;
+    if status == reqwest::StatusCode::OK {
+      let minted = json_body(&body)?;
+      ensure(
+        minted["access_token"]
+          .as_str()
+          .is_some_and(|token| !token.is_empty()),
+        "the winner must mint an access token",
+      )?;
+      ensure(
+        minted["refresh_token"]
+          .as_str()
+          .is_some_and(|token| !token.is_empty()),
+        "the single winner must create the token family",
+      )?;
+      successes += 1;
+    } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
+      ensure(
+        json_body(&body)?["error"] == "temporarily_unavailable",
+        format!("store outage must be temporarily_unavailable: {body}"),
+      )?;
+      ensure(!body.contains("access_token"), "failure must mint nothing")?;
+      unavailable += 1;
+    } else {
+      ensure_error(status, &body, "invalid_grant")?;
+      rejections += 1;
+    }
+  }
+  ensure(
+    successes == 1,
+    format!(
+      "exactly one racer must redeem the code (got {successes}; invalid_grant={rejections}, \
+       unavailable={unavailable})"
+    ),
+  )?;
+  ensure(
+    rejections + unavailable == 1,
+    "every other racer must be rejected",
+  )?;
+  Ok(())
+}
+
+/// Concurrent `/authorize` GETs on one PAR `request_uri`: exactly one proceeds
+/// into login, every other racer is the fail-closed `invalid_request` — the
+/// handle is consumed atomically and cannot be replayed.
+#[tokio::test]
+async fn e2e_concurrent_par_consumption_fail_closed() -> Result<(), TestError> {
+  let world = setup().await?;
+  let (challenge, _verifier) = oauth2_pkce();
+  let (status, _, body) = world
+    .post_form(
+      "/par",
+      &[
+        ("client_id", CLIENT_ID),
+        ("redirect_uri", REDIRECT_URI),
+        ("response_type", "code"),
+        ("scope", "read"),
+        ("state", "par-race-state"),
+        ("code_challenge", challenge.as_str()),
+        ("code_challenge_method", "S256"),
+      ],
+      Some((CLIENT_ID, CLIENT_SECRET)),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::CREATED,
+    format!("PAR push must 201: {status} {body}"),
+  )?;
+  let request_uri = json_body(&body)?["request_uri"]
+    .as_str()
+    .ok_or_else(|| fail("PAR must return request_uri"))?
+    .to_owned();
+
+  let client = reqwest::Client::builder()
+    .redirect(reqwest::redirect::Policy::none())
+    .timeout(std::time::Duration::from_secs(60))
+    .build()?;
+  let mut set = tokio::task::JoinSet::new();
+  for _ in 0..2 {
+    let client = client.clone();
+    let base = world.base.clone();
+    let target = format!(
+      "/authorize?client_id={CLIENT_ID}&request_uri={}",
+      encoded(&request_uri)
+    );
+    set.spawn(async move { client.get(format!("{base}{target}")).send().await });
+  }
+  let mut winners = 0;
+  let mut rejections = 0;
+  let mut unavailable = 0;
+  while let Some(outcome) = set.join_next().await {
+    let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
+    let status = response.status();
+    let location = response
+      .headers()
+      .get("location")
+      .and_then(|value| value.to_str().ok())
+      .map(str::to_owned);
+    let body = response.text().await?;
+    if status == reqwest::StatusCode::SEE_OTHER {
+      ensure(
+        location
+          .as_deref()
+          .is_some_and(|target| target.starts_with("/login?request_id=")),
+        format!("the winner must enter login: {location:?} {body}"),
+      )?;
+      winners += 1;
+    } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
+      ensure(
+        json_body(&body)?["error"] == "temporarily_unavailable",
+        format!("store outage must be temporarily_unavailable: {body}"),
+      )?;
+      unavailable += 1;
+    } else {
+      ensure(
+        status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::FOUND,
+        format!("a losing PAR authorize must be rejected: {status} {body}"),
+      )?;
+      let rejected = body.contains("invalid_request")
+        || location
+          .as_deref()
+          .is_some_and(|target| target.contains("error=invalid_request"));
+      ensure(
+        rejected,
+        format!("a losing PAR authorize must be invalid_request: {status} {location:?} {body}"),
+      )?;
+      ensure(
+        location
+          .as_deref()
+          .is_none_or(|target| !target.starts_with("/login")),
+        "a losing racer must never enter login",
+      )?;
+      ensure(
+        !body.contains("request_id="),
+        "a losing racer must not start a flow",
+      )?;
+      rejections += 1;
+    }
+  }
+  ensure(
+    winners == 1,
+    format!("exactly one racer must consume the PAR handle (got {winners})"),
+  )?;
+  ensure(
+    rejections + unavailable == 1,
+    "every other racer must be rejected",
+  )?;
+  Ok(())
+}
+
+/// Concurrent `/token` calls reusing one DPoP `jti`: exactly one mints, every
+/// other racer is `invalid_dpop_proof` — the replay marker is written with
+/// `SET NX`.
+#[tokio::test]
+async fn e2e_concurrent_dpop_jti_reuse_fail_closed() -> Result<(), TestError> {
+  let world = setup().await?;
+  let key = DpopKey::generate()?;
+  let htu = format!("{}/token", world.state.config.issuer);
+  let jti = uuid::Uuid::new_v4().to_string();
+  let mut proofs = Vec::with_capacity(2);
+  for _ in 0..2 {
+    proofs.push(key.proof_with_jti("POST", &htu, &jti)?);
+  }
+  let client = reqwest::Client::builder()
+    .redirect(reqwest::redirect::Policy::none())
+    .timeout(std::time::Duration::from_secs(60))
+    .build()?;
+  let mut set = tokio::task::JoinSet::new();
+  for proof in proofs {
+    let client = client.clone();
+    let base = world.base.clone();
+    set.spawn(async move {
+      client
+        .post(format!("{base}/token"))
+        .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
+        .header("dpop", proof)
+        .form(&[("grant_type", "client_credentials")])
+        .send()
+        .await
+    });
+  }
+  let mut successes = 0;
+  let mut rejections = 0;
+  let mut unavailable = 0;
+  while let Some(outcome) = set.join_next().await {
+    let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
+    let status = response.status();
+    let body = response.text().await?;
+    if status == reqwest::StatusCode::OK {
+      ensure(
+        json_body(&body)?["token_type"] == "DPoP",
+        "the winner must mint a DPoP-bound token",
+      )?;
+      successes += 1;
+    } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
+      ensure(
+        json_body(&body)?["error"] == "temporarily_unavailable",
+        format!("store outage must be temporarily_unavailable: {body}"),
+      )?;
+      ensure(!body.contains("access_token"), "failure must mint nothing")?;
+      unavailable += 1;
+    } else {
+      ensure_error(status, &body, "invalid_dpop_proof")?;
+      rejections += 1;
+    }
+  }
+  ensure(
+    successes == 1,
+    format!(
+      "exactly one racer must mint (got {successes}; invalid_dpop_proof={rejections}, \
+       unavailable={unavailable})"
+    ),
+  )?;
+  ensure(
+    rejections + unavailable == 1,
+    "every other racer must be rejected",
+  )?;
+  Ok(())
+}
+
+/// Concurrent `/token` polls of one approved device code: exactly one mints,
+/// every other racer fails closed (`expired_token`/`invalid_grant`, or the
+/// RFC 8628 back-off when it races the interval stamp) — the approval is
+/// consumed atomically and is never redeemed twice.
+#[tokio::test]
+async fn e2e_concurrent_device_poll_fail_closed() -> Result<(), TestError> {
+  let world = setup().await?;
+  let approver = user_repo::create_user(
+    &world.pool,
+    &world.state.keys,
+    world.state.config.pii_dual_write,
+    &user_repo::NewUser {
+      email: "e2e-device-approver@example.test".to_owned(),
+      password_hash: "e2e-not-a-login-credential".to_owned(),
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  user_scope_repo::entitle(&world.pool, approver.id, "read", None)
+    .await
+    .map_err(|error| fail(format!("entitle failed: {error}")))?;
+
+  let (status, _, body) = world
+    .post_form(
+      "/device_authorization",
+      &[("client_id", CLIENT_ID), ("scope", "read")],
+      Some((CLIENT_ID, CLIENT_SECRET)),
+      &[],
+    )
+    .await?;
+  ensure(
+    status.is_success(),
+    format!("device authorization must 200: {status} {body}"),
+  )?;
+  let device_code = json_body(&body)?["device_code"]
+    .as_str()
+    .ok_or_else(|| fail("device authorization must return device_code"))?
+    .to_owned();
+
+  // Approve through the store decision the `/device` UI records.
+  let device_hash = hash_token(&world.state.keys, &device_code)
+    .map_err(|error| fail(format!("device hash failed: {error}")))?;
+  let mut conn = world.state.redis.clone();
+  let approved = decide(
+    &mut conn,
+    &world.store,
+    &device_hash,
+    DeviceStatus::Approved,
+    &approver.id.to_string(),
+    &uuid::Uuid::new_v4().to_string(),
+  )
+  .await
+  .map_err(|error| fail(format!("device approval failed: {error}")))?;
+  ensure(approved, "the device record must be live to approve")?;
+
+  let client = reqwest::Client::builder()
+    .redirect(reqwest::redirect::Policy::none())
+    .timeout(std::time::Duration::from_secs(60))
+    .build()?;
+  let mut set = tokio::task::JoinSet::new();
+  for _ in 0..2 {
+    let client = client.clone();
+    let base = world.base.clone();
+    let device_code = device_code.clone();
+    set.spawn(async move {
+      client
+        .post(format!("{base}/token"))
+        .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
+        .form(&[
+          ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
+          ("device_code", device_code.as_str()),
+        ])
+        .send()
+        .await
+    });
+  }
+  let mut successes = 0;
+  let mut rejections = 0;
+  let mut unavailable = 0;
+  while let Some(outcome) = set.join_next().await {
+    let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
+    let status = response.status();
+    let body = response.text().await?;
+    if status == reqwest::StatusCode::OK {
+      let minted = json_body(&body)?;
+      ensure(
+        minted["access_token"]
+          .as_str()
+          .is_some_and(|token| !token.is_empty()),
+        "the winner must mint an access token",
+      )?;
+      ensure(
+        minted["refresh_token"]
+          .as_str()
+          .is_some_and(|token| !token.is_empty()),
+        "the winner must mint a refresh token",
+      )?;
+      successes += 1;
+    } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
+      ensure(
+        json_body(&body)?["error"] == "temporarily_unavailable",
+        format!("store outage must be temporarily_unavailable: {body}"),
+      )?;
+      ensure(!body.contains("access_token"), "failure must mint nothing")?;
+      unavailable += 1;
+    } else {
+      let error_code = json_body(&body)?["error"]
+        .as_str()
+        .unwrap_or_default()
+        .to_owned();
+      // A racer that lands after the winner's interval stamp gets the RFC 8628
+      // back-off; one that lands after the atomic consume gets `expired_token`.
+      ensure(
+        matches!(
+          error_code.as_str(),
+          "expired_token" | "invalid_grant" | "slow_down"
+        ),
+        format!("a losing device poll must fail closed, got {status}: {body}"),
+      )?;
+      ensure(!body.contains("access_token"), "failure must mint nothing")?;
+      rejections += 1;
+    }
+  }
+  ensure(
+    successes == 1,
+    format!(
+      "exactly one poll must mint (got {successes}; expired_or_invalid={rejections}, \
+       unavailable={unavailable})"
+    ),
+  )?;
+  ensure(
+    rejections + unavailable == 1,
+    "every other poll must fail closed",
+  )?;
+  Ok(())
+}
+
 /// A public client's refresh grant without a fresh DPoP proof is
 /// `invalid_grant` (the stored `jkt` alone never mints).
 #[tokio::test]

+ 256 - 0
tests/e2e_transport.rs

@@ -0,0 +1,256 @@
+//! `test(e2e_transport)` HTTP transport-layer robustness over the live router.
+//!
+//! The sibling e2e suites drive `/token`, `/par`, and `/register` only with
+//! well-formed bodies. This suite pins the transport contract for requests
+//! that are malformed before (or independently of) grant logic: empty bodies,
+//! missing and duplicated parameters, wrong content types, malformed percent
+//! encoding, and bodies past the configured limit. Expected codes come from
+//! the documented contract (docs/OAUTH.md error table: "Missing or duplicated
+//! parameter" is `invalid_request` 400; docs/SECURITY.md input safety) and
+//! from the axum extractor contract for content type — never by copying
+//! observed output. No request may panic or answer 5xx.
+
+#[path = "e2e/harness.rs"]
+#[allow(dead_code)]
+mod harness;
+
+use d_oauth::{db::TokenEndpointAuthMethod, routes::MAX_BODY_BYTES};
+use harness::{
+  TestError, World, ensure, fail, json_body, open_rate_limit, seed_client, seed_scope,
+};
+use reqwest::{
+  StatusCode,
+  header::{self, HeaderMap},
+};
+
+/// Fixture confidential client.
+const CLIENT_ID: &str = "e2e-transport-client";
+/// Fixture client secret.
+const CLIENT_SECRET: &str = "e2e-transport-secret-aaaabbbbccccdddd";
+/// Form content type the OAuth endpoints require.
+const FORM: &str = "application/x-www-form-urlencoded";
+/// A redirect URI used where validation stops before the catalog lookup.
+const REDIRECT_URI: &str = "https://client.example.test/cb";
+
+/// One world plus a seeded confidential client and enabled scope.
+async fn setup() -> Result<World, TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    CLIENT_ID,
+    Some(CLIENT_SECRET),
+    &["authorization_code", "refresh_token", "client_credentials"],
+    "read",
+    TokenEndpointAuthMethod::ClientSecretBasic,
+  )
+  .await?;
+  seed_scope(&world.pool, "read").await?;
+  Ok(world)
+}
+
+/// `POST {path}` with a raw body and an explicit content type.
+async fn post_raw(
+  world: &World,
+  path: &str,
+  content_type: &str,
+  body: &str,
+) -> Result<(StatusCode, HeaderMap, String), TestError> {
+  let response = world
+    .client
+    .post(format!("{}{path}", world.base))
+    .header(header::CONTENT_TYPE, content_type)
+    .body(body.to_owned())
+    .send()
+    .await?;
+  let status = response.status();
+  let headers = response.headers().clone();
+  let body = response.text().await?;
+  Ok((status, headers, body))
+}
+
+/// Assert the documented `invalid_request` 400 JSON error, and no token.
+fn ensure_invalid_request(status: StatusCode, body: &str) -> Result<(), TestError> {
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("must be a 400 invalid_request, got {status}: {body}"),
+  )?;
+  let error = json_body(body)?;
+  ensure(
+    error["error"] == "invalid_request",
+    format!("must carry invalid_request, got {body}"),
+  )?;
+  ensure(
+    !body.contains("access_token"),
+    "a rejected request must not carry an access token",
+  )
+}
+
+/// Read one response header or fail the test.
+fn header_str(headers: &HeaderMap, name: &str) -> Result<String, TestError> {
+  headers
+    .get(name)
+    .and_then(|value| value.to_str().ok())
+    .map(str::to_owned)
+    .ok_or_else(|| fail(format!("response lacks {name} header")))
+}
+
+/// An empty form body omits the required `grant_type`: RFC 6749 §5.2 maps a
+/// missing required parameter to `invalid_request` 400 (docs/OAUTH.md).
+#[tokio::test]
+async fn e2e_transport_token_empty_body_is_invalid_request() -> Result<(), TestError> {
+  let world = setup().await?;
+  let (status, _, body) = world.post_form("/token", &[], None, &[]).await?;
+  ensure_invalid_request(status, &body)
+}
+
+/// RFC 6749 §5.2 and docs/OAUTH.md: a parameter "more than once" is
+/// `invalid_request` 400, whether the duplicate is the grant or the client.
+#[tokio::test]
+async fn e2e_transport_token_duplicate_params_are_invalid_request() -> Result<(), TestError> {
+  let world = setup().await?;
+  let base = [
+    ("grant_type", "client_credentials"),
+    ("client_id", CLIENT_ID),
+  ];
+  let auth = Some((CLIENT_ID, CLIENT_SECRET));
+
+  let duplicated_grant: Vec<(&str, &str)> = vec![
+    ("grant_type", "client_credentials"),
+    ("grant_type", "refresh_token"),
+    ("client_id", CLIENT_ID),
+  ];
+  let (status, _, body) = world
+    .post_form("/token", &duplicated_grant, auth, &[])
+    .await?;
+  ensure_invalid_request(status, &body)?;
+
+  let duplicated_client = [base[0], base[1], ("client_id", "e2e-transport-other")];
+  let (status, _, body) = world
+    .post_form("/token", &duplicated_client, auth, &[])
+    .await?;
+  ensure_invalid_request(status, &body)
+}
+
+/// A request that is not a form keeps the extractor's 415: it never reaches
+/// grant logic and must never 5xx.
+#[tokio::test]
+async fn e2e_transport_token_wrong_content_type_rejected_cleanly() -> Result<(), TestError> {
+  let world = setup().await?;
+  let body = format!("grant_type=client_credentials&client_id={CLIENT_ID}");
+  for content_type in ["text/plain", "application/json"] {
+    let (status, _, response_body) = post_raw(&world, "/token", content_type, &body).await?;
+    ensure(
+      status == StatusCode::UNSUPPORTED_MEDIA_TYPE,
+      format!("{content_type} must 415, got {status}: {response_body}"),
+    )?;
+    ensure(
+      !response_body.is_empty(),
+      format!("{content_type} rejection must carry a clean body"),
+    )?;
+  }
+  Ok(())
+}
+
+/// Malformed percent-encoding decodes lossily into an unknown `grant_type`
+/// value, so the documented code for "a grant_type outside the five" is
+/// `unsupported_grant_type` 400 — never a panic or 5xx.
+#[tokio::test]
+async fn e2e_transport_token_malformed_encoding_is_clean_4xx() -> Result<(), TestError> {
+  let world = setup().await?;
+  for malformed in [
+    "grant_type=%ZZ&client_id=e2e-transport-client",
+    "grant_type=%E0%A4%A&client_id=e2e-transport-client",
+  ] {
+    let (status, _, body) = post_raw(&world, "/token", FORM, malformed).await?;
+    ensure(
+      status == StatusCode::BAD_REQUEST,
+      format!("malformed encoding must be a clean 400, got {status}: {body}"),
+    )?;
+    let error = json_body(&body)?;
+    ensure(
+      error["error"] == "unsupported_grant_type",
+      format!("unknown grant value must be unsupported_grant_type, got {body}"),
+    )?;
+    ensure(
+      !body.contains("access_token"),
+      "a rejected request must not carry an access token",
+    )?;
+  }
+  Ok(())
+}
+
+/// Bodies past the router's configured limit are rejected at the layer
+/// before any handler, on both form endpoints, with the tower limit's 413.
+#[tokio::test]
+async fn e2e_transport_oversized_body_is_rejected() -> Result<(), TestError> {
+  let world = setup().await?;
+  let padding = "a".repeat(MAX_BODY_BYTES + 1024);
+  let body = format!("grant_type=client_credentials&client_id={CLIENT_ID}&padding={padding}");
+  for path in ["/token", "/par"] {
+    let (status, _, response_body) = post_raw(&world, path, FORM, &body).await?;
+    ensure(
+      status == StatusCode::PAYLOAD_TOO_LARGE,
+      format!("{path} oversized body must 413, got {status}: {response_body}"),
+    )?;
+  }
+  Ok(())
+}
+
+/// PAR mirrors its core rule suite at the HTTP layer: a missing
+/// `response_type` and a duplicated one whose surviving value is not `code`
+/// are both `invalid_request` 400 after a valid client authentication.
+#[tokio::test]
+async fn e2e_transport_par_missing_and_duplicate_params() -> Result<(), TestError> {
+  let world = setup().await?;
+  let auth = Some((CLIENT_ID, CLIENT_SECRET));
+
+  // Missing response_type (the authenticated client_id matches).
+  let missing = [("client_id", CLIENT_ID), ("redirect_uri", REDIRECT_URI)];
+  let (status, _, body) = world.post_form("/par", &missing, auth, &[]).await?;
+  ensure_invalid_request(status, &body)?;
+
+  // Duplicated response_type: the surviving (last) value is not `code`.
+  let duplicated = [
+    ("client_id", CLIENT_ID),
+    ("response_type", "code"),
+    ("response_type", "authorization_code"),
+    ("redirect_uri", REDIRECT_URI),
+  ];
+  let (status, _, body) = world.post_form("/par", &duplicated, auth, &[]).await?;
+  ensure_invalid_request(status, &body)
+}
+
+/// `/register` rejects malformed JSON cleanly before auth, and a valid body
+/// without a bearer token is the documented `invalid_client` 401 challenge.
+#[tokio::test]
+async fn e2e_transport_register_malformed_json_and_missing_auth() -> Result<(), TestError> {
+  let world = setup().await?;
+
+  let (status, _, body) = post_raw(&world, "/register", "application/json", "{not-json").await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("malformed JSON must be a clean 400, got {status}: {body}"),
+  )?;
+
+  let payload = serde_json::json!({"redirect_uris": ["https://app.example.test/cb"]});
+  let (status, headers, body) = post_raw(
+    &world,
+    "/register",
+    "application/json",
+    &payload.to_string(),
+  )
+  .await?;
+  ensure(
+    status == StatusCode::UNAUTHORIZED,
+    format!("missing initial access token must 401, got {status}: {body}"),
+  )?;
+  ensure(
+    header_str(&headers, "www-authenticate")?.starts_with("Bearer"),
+    "401 must carry the Bearer challenge",
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "invalid_client",
+    format!("missing auth must render invalid_client, got {body}"),
+  )
+}

+ 175 - 3
tests/mtls.rs

@@ -2,9 +2,9 @@
 //!
 //! Covers the two RFC 8705 verifiers (chain-skipping `self_signed` vs
 //! `WebPkiClientVerifier` + client-CA bundle), thumbprint/DN matching,
-//! rejection of unknown/missing/ambiguous identities, the trusted-proxy
-//! header mode, and the request-extension injection over a real TLS
-//! handshake through [`d_oauth::tls::serve_tls`].
+//! rejection of unknown/missing/ambiguous/cross-wired/expired identities,
+//! the trusted-proxy header mode, and the request-extension injection over
+//! a real TLS handshake through [`d_oauth::tls::serve_tls`].
 //!
 //! Test names contain `mtls` so
 //! `cargo nextest run -E 'test(mtls)'` selects exactly them. Bodies use
@@ -299,6 +299,178 @@ async fn mtls_missing_cert_rejects_mtls_clients() -> TestResult {
   Ok(())
 }
 
+/// RFC 8705 §2: the presented certificate must match the identity registered
+/// for the requesting `client_id`. A certificate enrolled for one client must
+/// not authenticate another, in either direction, and no `cnf` may be bound.
+#[tokio::test]
+async fn mtls_cross_wire_certificate_rejected_for_other_client() -> TestResult {
+  let pool = mtls_postgres().await?;
+  mtls_migrate(&pool).await?;
+  let keys = test_keys()?;
+  insert_mtls_client(
+    &pool,
+    "mtls-xwire-a",
+    "self_signed_tls_client_auth",
+    None,
+    Some(SELF_THUMBPRINT),
+  )
+  .await?;
+  insert_mtls_client(
+    &pool,
+    "mtls-xwire-b",
+    "self_signed_tls_client_auth",
+    None,
+    Some(PKI_THUMBPRINT),
+  )
+  .await?;
+  insert_mtls_client(&pool, "mtls-xwire-c", "tls_client_auth", Some(PKI_DN), None).await?;
+  let leaf_a = certs::cert_der(certs::CLIENT_SELF_CERT_PEM)?;
+  let leaf_b = certs::cert_der(certs::CLIENT_PKI_CERT_PEM)?;
+
+  // A's certificate while authenticating as B: B's thumbprint pin differs.
+  let result = mtls::authenticate_by_id(&pool, &keys, "mtls-xwire-b", Some(&leaf_a)).await;
+  assert!(matches!(result, Err(MtlsError::UnknownCertificate)));
+  assert!(is_invalid_client(result));
+  // B's certificate while authenticating as A: A's thumbprint pin differs.
+  let result = mtls::authenticate_by_id(&pool, &keys, "mtls-xwire-a", Some(&leaf_b)).await;
+  assert!(matches!(result, Err(MtlsError::UnknownCertificate)));
+  assert!(is_invalid_client(result));
+  // A's certificate while authenticating as the subject-DN client C.
+  let result = mtls::authenticate_by_id(&pool, &keys, "mtls-xwire-c", Some(&leaf_a)).await;
+  assert!(matches!(result, Err(MtlsError::UnknownCertificate)));
+  assert!(is_invalid_client(result));
+
+  // Controls: each client still binds with its own certificate, so the
+  // rejections above are the cross-wire and not an over-broad guard.
+  assert_eq!(
+    mtls::authenticate_by_id(&pool, &keys, "mtls-xwire-a", Some(&leaf_a))
+      .await?
+      .x5t_s256,
+    SELF_THUMBPRINT
+  );
+  assert_eq!(
+    mtls::authenticate_by_id(&pool, &keys, "mtls-xwire-b", Some(&leaf_b))
+      .await?
+      .x5t_s256,
+    PKI_THUMBPRINT
+  );
+  assert_eq!(
+    mtls::authenticate_by_id(&pool, &keys, "mtls-xwire-c", Some(&leaf_b))
+      .await?
+      .x5t_s256,
+    PKI_THUMBPRINT
+  );
+  Ok(())
+}
+
+/// The registered method is part of the credential: `tls_client_auth` is
+/// matched by subject DN, `self_signed_tls_client_auth` by exact thumbprint,
+/// and neither accepts the other method's identity.
+#[tokio::test]
+async fn mtls_auth_methods_not_interchangeable() -> TestResult {
+  let pool = mtls_postgres().await?;
+  mtls_migrate(&pool).await?;
+  let keys = test_keys()?;
+  insert_mtls_client(
+    &pool,
+    "mtls-method-pinned",
+    "self_signed_tls_client_auth",
+    None,
+    Some(SELF_THUMBPRINT),
+  )
+  .await?;
+  insert_mtls_client(
+    &pool,
+    "mtls-method-dn",
+    "tls_client_auth",
+    Some(PKI_DN),
+    None,
+  )
+  .await?;
+  let self_leaf = certs::cert_der(certs::CLIENT_SELF_CERT_PEM)?;
+  let pki_leaf = certs::cert_der(certs::CLIENT_PKI_CERT_PEM)?;
+
+  // A self-signed certificate is not the identity registered for the
+  // tls_client_auth client.
+  let result = mtls::authenticate_by_id(&pool, &keys, "mtls-method-dn", Some(&self_leaf)).await;
+  assert!(matches!(result, Err(MtlsError::UnknownCertificate)));
+  assert!(is_invalid_client(result));
+  // A CA-issued certificate is not the thumbprint pinned for the
+  // self_signed_tls_client_auth client.
+  let result = mtls::authenticate_by_id(&pool, &keys, "mtls-method-pinned", Some(&pki_leaf)).await;
+  assert!(matches!(result, Err(MtlsError::UnknownCertificate)));
+  assert!(is_invalid_client(result));
+
+  // Controls: each method accepts exactly its own registered identity.
+  assert_eq!(
+    mtls::authenticate_by_id(&pool, &keys, "mtls-method-pinned", Some(&self_leaf))
+      .await?
+      .x5t_s256,
+    SELF_THUMBPRINT
+  );
+  assert_eq!(
+    mtls::authenticate_by_id(&pool, &keys, "mtls-method-dn", Some(&pki_leaf))
+      .await?
+      .x5t_s256,
+    PKI_THUMBPRINT
+  );
+  Ok(())
+}
+
+/// RFC 8705 §2 and RFC 5280 §6.3.3: a certificate outside its validity
+/// window authenticates nobody, even when its thumbprint or subject DN is
+/// registered byte-for-byte; it must never bind a `cnf`.
+#[tokio::test]
+async fn mtls_expired_certificate_never_binds_cnf() -> TestResult {
+  let pool = mtls_postgres().await?;
+  mtls_migrate(&pool).await?;
+  let keys = test_keys()?;
+  let expired = certs::cert_der(certs::CLIENT_EXPIRED_CERT_PEM)?;
+  let expired_thumbprint = mtls::thumbprint_b64url(expired.as_ref());
+  let expired_dn = mtls::subject_dn(expired.as_ref())?;
+  insert_mtls_client(
+    &pool,
+    "mtls-expired-pinned",
+    "self_signed_tls_client_auth",
+    None,
+    Some(&expired_thumbprint),
+  )
+  .await?;
+  insert_mtls_client(
+    &pool,
+    "mtls-expired-dn",
+    "tls_client_auth",
+    Some(&expired_dn),
+    None,
+  )
+  .await?;
+
+  let pinned = client_repo::find_active_by_client_id(&pool, &keys, "mtls-expired-pinned")
+    .await?
+    .ok_or(MtlsError::UnknownClient)?;
+  assert_eq!(
+    pinned.tls_client_auth_cert_thumbprint.as_deref(),
+    Some(expired_thumbprint.as_str())
+  );
+  let by_dn = client_repo::find_active_by_client_id(&pool, &keys, "mtls-expired-dn")
+    .await?
+    .ok_or(MtlsError::UnknownClient)?;
+  assert_eq!(
+    by_dn.tls_client_auth_subject_dn.as_deref(),
+    Some(expired_dn.as_str())
+  );
+
+  // The registered identity matches; only the validity window rejects, and
+  // no `cnf` may be produced for either method.
+  let result = mtls::authenticate_by_id(&pool, &keys, "mtls-expired-pinned", Some(&expired)).await;
+  assert!(matches!(result, Err(MtlsError::ExpiredCertificate)));
+  assert!(is_invalid_client(result));
+  let result = mtls::authenticate_by_id(&pool, &keys, "mtls-expired-dn", Some(&expired)).await;
+  assert!(matches!(result, Err(MtlsError::ExpiredCertificate)));
+  assert!(is_invalid_client(result));
+  Ok(())
+}
+
 /// An encrypted subject-DN row matches after decryption; an unknown DN
 /// fails closed; a corrupt `tls_client_auth_subject_dn_enc` surfaces as
 /// 503 `temporarily_unavailable`, never a flattened `invalid_client`.

+ 10 - 5
tests/mtls/certs.rs

@@ -1,10 +1,12 @@
 //! `test(mtls)` certificate fixtures.
 //!
-//! Short-lived RSA-2048 fixtures minted with `openssl` for task 21 only:
-//! a TLS server identity, a self-signed client, a client CA plus a
-//! CA-signed client, and an unregistered rogue client. They expire days
-//! after minting; if a fixture expires, re-mint with the same subjects and
-//! update the pinned `x5t#S256` vectors in `tests/mtls.rs`.
+//! RSA-2048 fixtures minted with `openssl` for task 21 only: a TLS server
+//! identity, a self-signed client, a client CA plus a CA-signed client, an
+//! unregistered rogue client, and an already-expired client. The live
+//! fixtures expire years after minting; if one expires, re-mint with the
+//! same subjects and update the pinned `x5t#S256` vectors in
+//! `tests/mtls.rs`. The expired fixture is deliberately outside its
+//! validity window and must stay that way.
 
 use base64::{Engine as _, engine::general_purpose::STANDARD};
 use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
@@ -25,6 +27,9 @@ pub const CLIENT_PKI_CERT_PEM: &str = include_str!("certs/client-pki-cert.pem");
 pub const CLIENT_PKI_KEY_PEM: &str = include_str!("certs/client-pki-key.pem");
 /// Unregistered self-signed client certificate (`CN=rogue, O=Example`).
 pub const CLIENT_ROGUE_CERT_PEM: &str = include_str!("certs/client-rogue-cert.pem");
+/// Expired self-signed client certificate (`CN=mtls-expired-client,
+/// O=Example`; valid 2020-01-01 to 2020-12-31 only).
+pub const CLIENT_EXPIRED_CERT_PEM: &str = include_str!("certs/client-expired-cert.pem");
 
 /// Fixture errors.
 #[derive(Debug, thiserror::Error)]

+ 20 - 0
tests/mtls/certs/client-expired-cert.pem

@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDQTCCAimgAwIBAgIUPc7fILVEKeTX4CKTFDrVodK23CQwDQYJKoZIhvcNAQEL
+BQAwMDEQMA4GA1UECgwHRXhhbXBsZTEcMBoGA1UEAwwTbXRscy1leHBpcmVkLWNs
+aWVudDAeFw0yMDAxMDEwMDAwMDBaFw0yMDEyMzEwMDAwMDBaMDAxEDAOBgNVBAoM
+B0V4YW1wbGUxHDAaBgNVBAMME210bHMtZXhwaXJlZC1jbGllbnQwggEiMA0GCSqG
+SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDdOu/8+Bwfp2pJAYNsI/CEFHGxK8MmXfmK
+LdgKqd0ZliTYF5ZZnfclRi5qeVcIuzKI6FfpLMCgrK9CSSSiQuF7TD575H+Anlqm
+SQiya72UJMlHVySYI+p0Gr583jT0/NA2VDs4GsmQtMbQYh+OxfVbYhSmSZGd5V+o
+rgDWWW7PPCw5GIyGPQP6idalJKGfj6/jWwBzWrShfMN3EPvUMyTTYbH3n9JgPYiJ
+tKnpcKfm3Ou4OeOzLEyX4GVpCUFgX2IN1V9VfOSNKqW81a9OGjFkTGPm9Grp+wME
+T2/y485io+rtDlgNzspcWcRxwkK9E9TV3tz0VxKXlyro1uHPZbzZAgMBAAGjUzBR
+MB0GA1UdDgQWBBQyHqleakfY9AhBp6tkS+3D3t/SxDAfBgNVHSMEGDAWgBQyHqle
+akfY9AhBp6tkS+3D3t/SxDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA
+A4IBAQB/ze6kA1HyViRyhNWasj9KYcf5vbbvDoPAPmuVVIjcRLYjWkJUzKuVxAcf
+33WczB/ezS/+AQYvOwtE1kMrCeFsgi/CHT8XbriE3XyRwA9EMAwtYwATyLpsXm8X
+i4ANP4eJzPh44f8XOw6pDhlTfcjghef9kuaRtUdvI4jzxhGpN+Tyq9b8kZYARVH1
+zSBilMflXz3if/pWTwb7zX3Mp+o/Yl1aP8BA9XUr2KgemVf47GdnUw2CDH5/HEcT
+4sSiSbdi7CPvF+dt/UzYLkAlvXYx4InZiliOKQqFDaE5I3svkP3f6Wp+flSjT7su
+PQuj+82dfTdb3T1WYEehi+j58g6+
+-----END CERTIFICATE-----