瀏覽代碼

Fixed bugs

Efren Yevale Varela 2 周之前
父節點
當前提交
86d79758cd

+ 5 - 1
docs/ARCHITECTURE.md

@@ -88,7 +88,11 @@ non-urlencoded body and `400` on a malformed or non-UTF-8 body; a duplicated
 known field is JSON `invalid_request` on `/token`, `/introspect`,
 `/device_authorization`, and `/revoke` (PAR and authorize keep last-wins
 absorption). `/token` rejects a `resource` on token exchange before any catalog
-lookup.
+lookup. On `/token`, `/introspect`, and `/device_authorization` the client is
+authenticated before the catalog lookup, so an unauthenticated caller sees
+`invalid_client` rather than a catalog error; the authorization-code grant also
+validates the presented audience before consuming the code, so a bad resource
+does not burn it.
 
 ## The two listeners
 

+ 15 - 5
docs/OAUTH.md

@@ -330,7 +330,8 @@ This server's validation is **stricter than RFC 8707 §2**, which only
 SHOULD-NOTs a query component and leaves the identifier semantics to the
 authorization server:
 
-- Every value must be an absolute `https` URI with a host.
+- Every value must be an absolute `https` URI with a host and no userinfo,
+  query, or fragment.
 - It must exactly match an enabled catalog row. There is no prefix, suffix,
   sub-path, case folding, or wildcard match; `*` cannot be registered.
 - The RFC 8252 §7.3 loopback exception applies only to redirect URIs. It does
@@ -358,14 +359,16 @@ single-valued map cannot represent repeats.
 | --- | --- | --- |
 | `GET /authorize` | `PendingAuth.aud`, then the authorization-code record | The token request's set must equal the code's set; any subset, superset, different member, or an unbound request against a pinned code is `invalid_grant` (RFC 8707 §2). |
 | `POST /par` | The PAR record's `aud`; the pushed request is re-validated at authorize | The code carries the same set; the token request must match it exactly. |
-| `POST /device_authorization` | The device record's `aud` | An omitted `resource` at poll time inherits the pinned set; a presented set must equal it, else `invalid_grant`. |
+| `POST /device_authorization` | The device record's `aud` | An omitted `resource` at poll time inherits the pinned set; a presented set must equal it, else `invalid_grant`. The inherited set is re-validated against the current catalog at poll time; a disabled/deleted member or the flag turned off is `invalid_target` (the device code is consumed, so the client re-authorizes). |
 | `POST /token` with `client_credentials` | The access-token record | No refresh token is issued. |
-| `POST /token` with `refresh_token` | The rotated access and refresh records | See [Refresh audience narrowing](#refresh-audience-narrowing). |
+| `POST /token` with `refresh_token` | The rotated access and refresh records | See [Refresh audience narrowing](#refresh-audience-narrowing). The stored full set is re-validated against the current catalog before rotation. |
 
 At `/authorize` the catalog check runs after the redirect URI is validated, so a
 bad audience is delivered as an error redirect (`302`) with `invalid_target`
 rather than an HTML error page. At the other endpoints it is the usual JSON error
-response (`400`).
+response (`400`). On `/token`, `/introspect`, and `/device_authorization` the
+client is authenticated before the catalog lookup, so an unauthenticated caller
+observes `invalid_client` rather than a catalog error.
 
 ### Refresh audience narrowing
 
@@ -383,6 +386,11 @@ issued with. On rotation:
 - A valid subset narrows only the access token. The replacement refresh token
   keeps the full original set, so a later rotation can use the full audience
   again. Scope still intersects and is never widened.
+- Before anything is minted or rotated, the stored full set is re-validated
+  against the current enabled catalog. A member disabled or deleted since
+  issuance, or `resource_indicators_enabled` turned off, is `invalid_target`;
+  the presented refresh token stays live for a retry once the identifier is
+  enabled again.
 
 ### Token exchange is excluded
 
@@ -408,7 +416,8 @@ parameter. When present, the token is reported active only if its `aud` includes
 every requested value; otherwise the response is the ordinary inactive body
 (`{"active": false}`) and the token's metadata is not leaked. Inclusion is the
 test, so a token pinned to a superset stays active. Without `resource`,
-introspection remains audience-agnostic.
+introspection remains audience-agnostic. The caller is authenticated before the
+`resource` values are validated against the catalog.
 
 `token_type` is `DPoP` for an access token bound with `cnf.jkt` (RFC 9449
 §6.2), `Bearer` for an unbound or mTLS-only access token, and omitted for a
@@ -423,6 +432,7 @@ refresh token.
 | Unknown or disabled catalog row | `invalid_target` |
 | Token request set differs from the pinned code or device set | `invalid_grant` |
 | Device poll presents a set that differs from the pinned set | `invalid_grant` |
+| Refresh or device mint where the stored audience contains a disabled/deleted member, or the indicator flag is off | `invalid_target` (refresh stays live; device code is consumed) |
 | Refresh presents a superset, a different member, or a member against an unbound grant | `invalid_target` |
 | Introspect presents `resource` the token is not bound to | Inactive body (`200`) |
 | `resource` on token exchange | `invalid_request` |

+ 6 - 1
docs/OPERATIONS.md

@@ -153,7 +153,12 @@ never stand for an entire origin or a non-fetchable identifier. `create`
 upserts: it creates the row, updates its description, or leaves an unchanged
 row alone; it never silently re-enables an operator-disabled row, and `list`
 shows both states. `delete` removes the row outright (nothing references the
-catalog directly).
+catalog directly). Disabling or deleting an identifier stops new authorizations
+and, at mint time, refresh/device grants already bound to it: refresh fails
+`invalid_target` with the refresh token still live, device poll fails
+`invalid_target` with the code consumed, and auth-code redemption fails before
+consuming the code. Re-enable the identifier and the client re-authorizes (or
+retries the live refresh token).
 
 `client set-access-token-format --client-id <id> --format opaque|jwt` is the
 OPERATOR-ONLY opt-in to JWT access tokens for one client; `opaque` (the OAuth

+ 9 - 0
docs/SECURITY.md

@@ -106,6 +106,15 @@ contract is in [RS-CONTRACT.md](RS-CONTRACT.md); a resource server that skips th
 audience check re-opens the cross-resource replay this server's audience binding
 is meant to close.
 
+Disabling or deleting a `resource_servers` row is a kill switch for that
+audience: refresh and device mints re-validate the stored audience against the
+current enabled catalog, so a disabled/deleted member (or
+`resource_indicators_enabled` turned off) fails `invalid_target` instead of
+minting. A refresh token stays live; a device code is already consumed by the
+poll; an authorization code is not consumed because catalog validation now runs
+before the redemption. The client must re-authorize (or retry the live refresh
+token) once the identifier is enabled again.
+
 Last reviewed: 2026-10-21. File-mounted DB/Redis passwords, a mutable `:latest`
 image reference, NetworkPolicy, `automountServiceAccountToken: false`, scheduled
 fuzzing, and the CIMD outbound fetch surface are implemented in the shipped

+ 82 - 17
src/protocol/cimd.rs

@@ -297,6 +297,53 @@ impl From<CimdError> for AppError {
   }
 }
 
+/// The class of a short-cached fresh-fetch validation failure. It is stored as
+/// a tiny marker in `cimd:invalid:<sha256>` so a retry replays the original
+/// client-visible error: a bad redirect set keeps `invalid_redirect_uri`;
+/// every other validation failure stays `invalid_client_metadata`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum InvalidClass {
+  /// [`CimdError::InvalidRedirectUri`].
+  Redirect,
+  /// Every other validation failure, replayed as [`CimdError::InvalidJson`].
+  Metadata,
+}
+
+impl InvalidClass {
+  /// The stored marker value.
+  const fn marker(self) -> &'static str {
+    match self {
+      Self::Redirect => "redirect",
+      Self::Metadata => "metadata",
+    }
+  }
+
+  /// Classify a validation failure.
+  const fn of(error: &CimdError) -> Self {
+    match error {
+      CimdError::InvalidRedirectUri => Self::Redirect,
+      _ => Self::Metadata,
+    }
+  }
+
+  /// Replay a stored marker. An unknown marker (a legacy `"1"` or a corrupt
+  /// entry) fails closed as the generic metadata class.
+  const fn from_marker(raw: &str) -> Self {
+    match raw.as_bytes() {
+      b"redirect" => Self::Redirect,
+      _ => Self::Metadata,
+    }
+  }
+
+  /// The client-visible error replayed for this class.
+  const fn to_error(self) -> CimdError {
+    match self {
+      Self::Redirect => CimdError::InvalidRedirectUri,
+      Self::Metadata => CimdError::InvalidJson,
+    }
+  }
+}
+
 /// The cache key for a validated document: `{state}`-prefixed
 /// `cimd:<sha256(url)>`.
 ///
@@ -517,8 +564,10 @@ pub async fn fetch_and_validate(
 /// An origin-level fetch failure (`Fetch`/`InvalidJson`) is negatively cached
 /// for 30 seconds under `cimd:neg:<sha256(url)>`. A fresh-fetch validation
 /// failure is short-cached for [`INVALID_CACHE_TTL_SECS`] under
-/// `cimd:invalid:<sha256(url)>` as a single-flight completion marker, so a
-/// corrected document is seen within that short window; a validation failure
+/// `cimd:invalid:<sha256(url)>` as a single-flight completion marker tagged
+/// with the failure class (`redirect` or `metadata`), so a retry replays the
+/// original client-visible error and a corrected document is seen within that
+/// short window; a validation failure
 /// on a cached document writes no marker. A cached document is revalidated so
 /// catalog changes are honored during the 15-minute TTL. If the cache write
 /// after a successful validation fails, the validated document is still
@@ -555,8 +604,8 @@ pub async fn resolve(
   if let Some(document) = get_cached(conn, &key).await? {
     return validate_document(client_id_url, &document, validation);
   }
-  if is_invalid_cached(conn, &invalid_key).await? {
-    return Err(CimdError::Fetch);
+  if let Some(error) = is_invalid_cached(conn, &invalid_key).await? {
+    return Err(error);
   }
 
   let ttl_ms = lock_ttl_ms(limits);
@@ -587,8 +636,8 @@ pub async fn resolve(
     if let Some(document) = get_cached(conn, &key).await? {
       return validate_document(client_id_url, &document, validation);
     }
-    if is_invalid_cached(conn, &invalid_key).await? {
-      return Err(CimdError::Fetch);
+    if let Some(error) = is_invalid_cached(conn, &invalid_key).await? {
+      return Err(error);
     }
     // A holder that failed validation releases without a cache entry; stop
     // waiting as soon as the lock is gone and try to elect the next fetcher.
@@ -642,8 +691,8 @@ async fn resolve_under_lock(
   if let Some(document) = get_cached(conn, key).await? {
     return validate_document(client_id_url, &document, validation);
   }
-  if is_invalid_cached(conn, invalid_key).await? {
-    return Err(CimdError::Fetch);
+  if let Some(error) = is_invalid_cached(conn, invalid_key).await? {
+    return Err(error);
   }
   let document = match fetch_document(client_id_url, limits).await {
     Ok(document) => document,
@@ -656,7 +705,10 @@ async fn resolve_under_lock(
   let validated = match validate_document(client_id_url, &document, validation) {
     Ok(validated) => validated,
     Err(error) => {
-      if put_invalid(conn, invalid_key).await.is_err() {
+      if put_invalid(conn, invalid_key, InvalidClass::of(&error))
+        .await
+        .is_err()
+      {
         tracing::warn!("cimd invalid-document marker write failed");
       }
       return Err(error);
@@ -799,22 +851,35 @@ async fn put_negative(conn: &mut RedisConn, neg_key: &str) -> Result<(), CimdErr
     .map_err(cache_backend)
 }
 
-/// Whether a fresh-fetch validation failure is short-cached. A Redis failure
-/// is fail-closed [`CimdError::Backend`].
-async fn is_invalid_cached(conn: &mut RedisConn, invalid_key: &str) -> Result<bool, CimdError> {
-  let present: bool = redis::cmd("EXISTS")
+/// The class of a short-cached fresh-fetch validation failure; `None` when no
+/// marker is present. A Redis failure is fail-closed [`CimdError::Backend`].
+async fn is_invalid_cached(
+  conn: &mut RedisConn,
+  invalid_key: &str,
+) -> Result<Option<CimdError>, CimdError> {
+  let raw: Option<String> = redis::cmd("GET")
     .arg(invalid_key)
     .query_async(conn)
     .await
     .map_err(cache_backend)?;
-  Ok(present)
+  Ok(
+    raw
+      .as_deref()
+      .map(InvalidClass::from_marker)
+      .map(InvalidClass::to_error),
+  )
 }
 
-/// Record a fresh-fetch validation failure for [`INVALID_CACHE_TTL_SECS`].
-async fn put_invalid(conn: &mut RedisConn, invalid_key: &str) -> Result<(), CimdError> {
+/// Record a fresh-fetch validation failure for [`INVALID_CACHE_TTL_SECS`],
+/// tagged with its client-visible class so a retry replays it.
+async fn put_invalid(
+  conn: &mut RedisConn,
+  invalid_key: &str,
+  class: InvalidClass,
+) -> Result<(), CimdError> {
   redis::cmd("SET")
     .arg(invalid_key)
-    .arg("1")
+    .arg(class.marker())
     .arg("EX")
     .arg(INVALID_CACHE_TTL_SECS)
     .query_async::<()>(conn)

+ 54 - 49
src/protocol/cimd/tests.rs

@@ -708,9 +708,10 @@ async fn cimd_single_flight_waiter_observes_negative() -> TestResult {
   }
 }
 
-/// A fresh-fetch validation failure is short-cached under `cimd:invalid:`: a
-/// later resolve for the same URL fails fast with `Fetch` without a network
-/// fetch, and the marker TTL is the 2-second completion window.
+/// A fresh-fetch validation failure is short-cached under `cimd:invalid:`
+/// with its class marker: a later resolve for the same URL fails fast with
+/// the original error class without a network fetch, and the marker TTL is
+/// the 2-second completion window.
 #[tokio::test]
 async fn cimd_invalid_cache_blocks_fetch() -> TestResult {
   let manager = crate::test_support::redis_manager()
@@ -718,56 +719,60 @@ async fn cimd_invalid_cache_blocks_fetch() -> TestResult {
     .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
   let mut conn = RedisConn::Single(manager);
   let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
-  let url = "https://client.invalid/invalid-cached.json";
-  let key = invalid_cache_key(&prefix, url);
-  if !key.starts_with(&prefix) || !key.contains("cimd:invalid:") || key.contains("client.invalid") {
-    return Err(TestError::Unexpected(format!("bad invalid key {key}")));
-  }
-  redis::cmd("SET")
-    .arg(&key)
-    .arg("1")
-    .arg("EX")
-    .arg(2)
-    .query_async::<()>(&mut conn)
-    .await
-    .map_err(|error| TestError::Unexpected(format!("invalid seed: {error}")))?;
   let enabled = scopes();
-  match resolve(
-    &mut conn,
-    &prefix,
-    url,
-    &CimdValidation {
-      enabled_scopes: &enabled,
-      allow_localhost: false,
-    },
-    CimdLimits {
-      max_bytes: 5_120,
-      timeout: Duration::from_secs(1),
-    },
-  )
-  .await
-  {
-    Err(CimdError::Fetch) => {}
-    Err(other) => {
+  for (marker, expected_redirect) in [("metadata", false), ("redirect", true)] {
+    let url = format!("https://client.invalid/invalid-cached-{marker}.json");
+    let key = invalid_cache_key(&prefix, &url);
+    if !key.starts_with(&prefix) || !key.contains("cimd:invalid:") || key.contains("client.invalid")
+    {
+      return Err(TestError::Unexpected(format!("bad invalid key {key}")));
+    }
+    redis::cmd("SET")
+      .arg(&key)
+      .arg(marker)
+      .arg("EX")
+      .arg(2)
+      .query_async::<()>(&mut conn)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("invalid seed: {error}")))?;
+    match resolve(
+      &mut conn,
+      &prefix,
+      &url,
+      &CimdValidation {
+        enabled_scopes: &enabled,
+        allow_localhost: false,
+      },
+      CimdLimits {
+        max_bytes: 5_120,
+        timeout: Duration::from_secs(1),
+      },
+    )
+    .await
+    {
+      Err(CimdError::InvalidRedirectUri) if expected_redirect => {}
+      Err(CimdError::InvalidJson) if !expected_redirect => {}
+      Err(other) => {
+        return Err(TestError::Unexpected(format!(
+          "{marker}-cache hit must replay its class, got {other}"
+        )));
+      }
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "invalid-cache hit fetched or accepted a document".to_owned(),
+        ));
+      }
+    }
+    let ttl: i64 = redis::cmd("TTL")
+      .arg(&key)
+      .query_async(&mut conn)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("invalid ttl: {error}")))?;
+    if ttl <= 0 || ttl > 2 {
       return Err(TestError::Unexpected(format!(
-        "invalid-cache hit must be Fetch, got {other}"
+        "invalid key must carry a 2 s TTL, got {ttl}"
       )));
     }
-    Ok(_) => {
-      return Err(TestError::Unexpected(
-        "invalid-cache hit fetched or accepted a document".to_owned(),
-      ));
-    }
-  }
-  let ttl: i64 = redis::cmd("TTL")
-    .arg(&key)
-    .query_async(&mut conn)
-    .await
-    .map_err(|error| TestError::Unexpected(format!("invalid ttl: {error}")))?;
-  if ttl <= 0 || ttl > 2 {
-    return Err(TestError::Unexpected(format!(
-      "invalid key must carry a 2 s TTL, got {ttl}"
-    )));
   }
   Ok(())
 }

+ 3 - 0
src/protocol/cimd/tests_principal.rs

@@ -367,6 +367,7 @@ async fn redeem(
     ttl: &h.ttl,
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
+    resource_indicators_enabled: false,
     cimd: Some(CimdSettings {
       token_prefix: &h.token_prefix,
       max_bytes: 5_120,
@@ -573,6 +574,7 @@ async fn cimd_none_unbound_gets_bearer() -> TestResult {
       client_registered: &registered,
       user_entitled: &entitled,
       token_format: TokenFormat::Opaque,
+      resource_indicators_enabled: false,
       now_unix: now_unix(),
     },
   )
@@ -949,6 +951,7 @@ async fn cimd_none_cannot_introspect() -> TestResult {
   let ctx = IntrospectContext {
     issuer: ISSUER,
     now_unix: now_unix(),
+    resource_indicators_enabled: false,
     cimd: Some(CimdSettings {
       token_prefix: &token_prefix,
       max_bytes: 5_120,

+ 11 - 9
src/protocol/device.rs

@@ -265,15 +265,6 @@ pub async fn authorize_device(
   if inputs.client_id.trim().is_empty() {
     return Err(AppError::invalid_request("client_id is required"));
   }
-  // Resource indicators (RFC 8707) validate fail-closed before any state is
-  // written: off + present is `invalid_target`, and every value must be an
-  // absolute `https` URI with an exact enabled-catalog match.
-  let aud = crate::protocol::params::validate_resources(
-    stores.pool,
-    ctx.resource_indicators_enabled,
-    inputs.resource,
-  )
-  .await?;
   let used_basic = inputs.basic_header.is_some();
   let unknown_client = || {
     if used_basic {
@@ -350,6 +341,17 @@ pub async fn authorize_device(
     return Err(AppError::invalid_dpop_proof("DPoP proof is required"));
   }
 
+  // Resource indicators (RFC 8707) validate fail-closed after authentication
+  // and before any state is written: an unauthenticated caller observes
+  // `invalid_client` first, while off + present, a non-`https` value, or an
+  // unknown/disabled member is `invalid_target` here.
+  let aud = crate::protocol::params::validate_resources(
+    stores.pool,
+    ctx.resource_indicators_enabled,
+    inputs.resource,
+  )
+  .await?;
+
   if !client.grant_types.iter().any(|g| g == DEVICE_GRANT) {
     return Err(AppError::unauthorized_client(
       "client may not use the device authorization grant",

+ 33 - 24
src/protocol/introspect.rs

@@ -185,14 +185,15 @@ pub struct IntrospectHttpInputs<'a> {
   pub assertion: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
-  /// Validated, sorted, deduped `resource` values from the repeated form
-  /// parameter.
+  /// Raw repeated `resource` values from the form body, in wire order.
   ///
   /// This is a LOCAL EXTENSION to RFC 7662: the RFC defines no `resource`
-  /// parameter for introspection. When non-empty, the located token's `aud`
-  /// must include every requested member (see [`audience_includes`]) or the
-  /// response collapses to the inactive shape. An empty slice keeps
-  /// introspection audience-agnostic, exactly as RFC 7662 specifies.
+  /// parameter for introspection. The core validates the set against the
+  /// catalog AFTER authenticating the caller; when non-empty and valid, the
+  /// located token's `aud` must include every requested member (see
+  /// [`audience_includes`]) or the response collapses to the inactive shape.
+  /// An empty slice keeps introspection audience-agnostic, exactly as
+  /// RFC 7662 specifies.
   pub resource: &'a [String],
 }
 
@@ -214,6 +215,10 @@ pub struct IntrospectContext<'a> {
   pub issuer: &'a str,
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
+  /// Config `resource_indicators_enabled`: the presented `resource` set is
+  /// catalog-validated after authentication, so an unauthenticated caller
+  /// observes `invalid_client` (HTTP 401) before any catalog error.
+  pub resource_indicators_enabled: bool,
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off. Only `private_key_jwt` documents may
   /// introspect (RFC 7662 §2.3): a `none` principal is still rejected in
@@ -283,8 +288,19 @@ pub async fn introspect(
   }
   let client = authenticate(stores, ctx, inputs).await?;
 
+  // The presented set is catalog-validated AFTER authentication, so an
+  // unauthenticated caller observes `invalid_client` (HTTP 401) before any
+  // catalog error. The validated set feeds both the opaque filter below and
+  // the JWT path.
+  let validated = crate::protocol::params::validate_resources(
+    stores.pool,
+    ctx.resource_indicators_enabled,
+    inputs.resource,
+  )
+  .await?;
+
   if is_jwt_shape(raw) {
-    return introspect_jwt(stores, ctx, inputs, &client, raw).await;
+    return introspect_jwt(stores, ctx, &client, &validated, raw).await;
   }
   let prefer_refresh = inputs.token_type_hint == Some(REFRESH_TOKEN_HINT);
   let located = locate(ctx.service, stores.conn, stores.store, raw, prefer_refresh)
@@ -321,7 +337,7 @@ pub async fn introspect(
   // A pinned superset stays active; a missing member is indistinguishable
   // from an unknown token, so it collapses to the §2.2 inactive shape rather
   // than an error.
-  if !inputs.resource.is_empty() && !audience_includes(&located.aud, inputs.resource) {
+  if !validated.is_empty() && !audience_includes(&located.aud, &validated) {
     return Ok(IntrospectionResponse::inactive());
   }
   active_response(stores, ctx, located).await
@@ -377,8 +393,8 @@ fn audience_includes(bound: &[String], requested: &[String]) -> bool {
 async fn introspect_jwt(
   stores: &mut IntrospectStores<'_>,
   ctx: &IntrospectContext<'_>,
-  inputs: &IntrospectHttpInputs<'_>,
   client: &Client,
+  validated: &[String],
   raw: &str,
 ) -> Result<IntrospectionResponse, AppError> {
   let signer = match ctx.jwt_signer {
@@ -408,7 +424,7 @@ async fn introspect_jwt(
   if !subject_live {
     return Ok(IntrospectionResponse::inactive());
   }
-  if !inputs.resource.is_empty() && !audience_includes(&claims.aud, inputs.resource) {
+  if !validated.is_empty() && !audience_includes(&claims.aud, validated) {
     return Ok(IntrospectionResponse::inactive());
   }
   let token_type = if claims.cnf.as_ref().is_some_and(|cnf| cnf.jkt.is_some()) {
@@ -667,12 +683,14 @@ pub async fn read_act(
 /// lossily accepted raw non-UTF-8 bytes); a repeated known field is JSON
 /// `invalid_request` (the old `Form` returned 422 `Failed to deserialize
 /// form body`, so the new JSON 400 is the intended contract, not parity).
-/// A present `resource` indicator (RFC 8707) validates fail-closed
-/// (`invalid_target`) before any lookup runs.
+/// A present `resource` indicator (RFC 8707) is captured raw and handed to
+/// [`introspect`], which validates it fail-closed (`invalid_target`) only
+/// after authenticating the caller — an unauthenticated caller observes
+/// `invalid_client` (HTTP 401) first.
 ///
 /// RFC 7662 defines no `resource` parameter. This endpoint accepts it as a
-/// LOCAL EXTENSION: the validated set is handed to [`introspect`], which
-/// reports a token inactive unless its `aud` includes every requested value.
+/// LOCAL EXTENSION: the validated set filters the response, which reports a
+/// token inactive unless its `aud` includes every requested value.
 /// `revoke` and the token-exchange subject-token lookup stay
 /// audience-agnostic.
 pub async fn handler(
@@ -703,16 +721,6 @@ pub async fn handler(
   let form = IntrospectForm::from_map(&map);
   let resources =
     crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
-  let resources = match crate::protocol::params::validate_resources(
-    &state.pool,
-    state.config.resource_indicators_enabled,
-    &resources,
-  )
-  .await
-  {
-    Ok(resources) => resources,
-    Err(error) => return error.into_response(),
-  };
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());
@@ -739,6 +747,7 @@ pub async fn handler(
   let ctx = IntrospectContext {
     issuer: &state.config.issuer,
     now_unix: state.clock.now().unix_timestamp(),
+    resource_indicators_enabled: state.config.resource_indicators_enabled,
     cimd: state
       .config
       .cimd_enabled

+ 23 - 0
src/protocol/introspect/tests.rs

@@ -247,6 +247,7 @@ async fn run_core_full(
   let ctx = IntrospectContext {
     issuer: &h.config.issuer,
     now_unix: now_unix(),
+    resource_indicators_enabled: true,
     cimd: None,
     service: &service,
     jwt_signer: signer.as_ref(),
@@ -402,6 +403,28 @@ async fn test_introspect_unauthenticated_is_401() -> TestResult {
   Ok(())
 }
 
+/// Authentication precedes catalog validation: an unauthenticated caller
+/// presenting an unknown `resource` observes `invalid_client` 401, never
+/// `invalid_target`.
+#[tokio::test]
+async fn test_introspect_unauthenticated_with_unknown_resource_is_401() -> TestResult {
+  let h = harness().await?;
+  let (status, body) = post_form(
+    router(&h),
+    None,
+    "token=whatever&resource=https://ghost.introspect.test/",
+  )
+  .await?;
+
+  assert_eq!(status, StatusCode::UNAUTHORIZED);
+  assert_eq!(
+    body.get("error").and_then(Value::as_str),
+    Some("invalid_client"),
+    "auth must run before the catalog check"
+  );
+  Ok(())
+}
+
 #[tokio::test]
 async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
   let mut h = harness().await?;

+ 25 - 8
src/protocol/params.rs

@@ -284,9 +284,10 @@ pub fn query_pairs(raw: Option<&str>) -> Result<Vec<(String, String)>, Transport
 ///
 /// An empty input pins an empty set without touching the catalog. Otherwise
 /// the flag must be on (off + any `resource` present is `invalid_target`,
-/// never a silent drop), every value must be an absolute `https` URI — no
-/// loopback exception applies to audiences — and must exactly match an
-/// enabled `resource_servers` row. Returns the sorted deduped set for
+/// never a silent drop), every value must be an absolute `https` URI with a
+/// host and no userinfo, query, or fragment — no loopback exception applies
+/// to audiences — and must exactly match an enabled `resource_servers` row.
+/// Returns the sorted deduped set for
 /// pinning into `PendingAuth.aud` / PAR / code / device records; values are
 /// never delimiter-joined and scopes are never widened here.
 ///
@@ -325,14 +326,30 @@ pub async fn validate_resources(
   }
   let mut aud = Vec::with_capacity(distinct.len());
   for value in distinct {
-    let absolute_https = reqwest::Url::parse(value)
-      .ok()
-      .filter(|url| url.scheme() == "https" && url.has_host())
-      .is_some();
-    if !absolute_https {
+    let Ok(url) = reqwest::Url::parse(value) else {
       return Err(AppError::invalid_target(
         "resource must be an absolute https URI",
       ));
+    };
+    if url.scheme() != "https" || !url.has_host() {
+      return Err(AppError::invalid_target(
+        "resource must be an absolute https URI",
+      ));
+    }
+    if !url.username().is_empty() || url.password().is_some() {
+      return Err(AppError::invalid_target(
+        "resource must not contain userinfo",
+      ));
+    }
+    if url.fragment().is_some() {
+      return Err(AppError::invalid_target(
+        "resource must not contain a fragment",
+      ));
+    }
+    if url.query().is_some() {
+      return Err(AppError::invalid_target(
+        "resource must not contain a query",
+      ));
     }
     let known = resource_repo::find_enabled(pool, value).await?.is_some();
     if !known {

+ 39 - 0
src/protocol/params/tests.rs

@@ -293,6 +293,45 @@ async fn params_http_and_relative_resources_are_invalid_target() -> TestResult {
   Ok(())
 }
 
+/// Userinfo, a query, and a fragment are rejected syntactically before any
+/// catalog lookup: even an exact matching row cannot make them acceptable.
+#[tokio::test]
+async fn params_resources_reject_userinfo_query_fragment() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  for value in [
+    "https://user@api.example.test/path",
+    "https://user:pass@api.example.test/path",
+    "https://api.example.test/path?audience=x",
+    "https://api.example.test/path#frag",
+  ] {
+    // Seed the exact invalid shape: the syntactic gate must reject it before
+    // `resource_repo::find_enabled` could ever match this row.
+    resource_repo::upsert(&pool, value, "invalid shape").await?;
+    match validate_resources(&pool, true, &[value.to_owned()]).await {
+      Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
+      Err(error) => {
+        return Err(format!("wrong code for {value}: {}", error.code()).into());
+      }
+      Ok(_) => {
+        return Err(format!("resource with userinfo/query/fragment accepted: {value}").into());
+      }
+    }
+  }
+  Ok(())
+}
+
+/// A clean absolute `https` resource with a path passes and pins.
+#[tokio::test]
+async fn params_valid_resource_with_path_passes() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  resource_repo::upsert(&pool, "https://api.example.test/path", "api").await?;
+  let aud = validate_resources(&pool, true, &["https://api.example.test/path".to_owned()]).await?;
+  if aud != vec!["https://api.example.test/path".to_owned()] {
+    return Err(format!("clean resource must pass unchanged: {aud:?}").into());
+  }
+  Ok(())
+}
+
 /// Unknown and disabled catalog entries are `invalid_target`.
 #[tokio::test]
 async fn params_unknown_and_disabled_resources_are_invalid_target() -> TestResult {

+ 10 - 18
src/protocol/token/dispatch.rs

@@ -196,8 +196,10 @@ struct Dispatch<'a> {
   form: &'a TokenForm,
   /// Verified peer chain injected by the TLS accept loop, when present.
   peer_certs: Option<&'a PeerCertificates>,
-  /// Validated resource audiences (RFC 8707) from this token request, sorted
-  /// and deduped; empty when the request carried no `resource`.
+  /// Raw repeated `resource` values (RFC 8707) from this token request, in
+  /// wire order; empty when the request carried no `resource`. Each grant
+  /// validates this set against the catalog AFTER authenticating the client,
+  /// so an unauthenticated caller observes `invalid_client` first.
   resources: &'a [String],
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off.
@@ -445,11 +447,11 @@ pub async fn handler(
   // the extractor consumed the raw body, hiding repeats. The explicit
   // exact-MIME gate below is the second content-type check (see above); any
   // other transport failure keeps the documented `invalid_request` mapping
-  // ("includes a parameter more than once"). The
-  // repeated `resource` indicator (RFC 8707) is captured in order by
-  // `params::values_of` and validated fail-closed here; the
-  // authorization-code branch binds the validated set to the redeemed code,
-  // and the remaining grants wire it in later todos.
+  // ("includes a parameter more than once"). The repeated `resource`
+  // indicator (RFC 8707) is captured in order by `params::values_of` and
+  // carried raw on the dispatcher: each grant validates it against the
+  // catalog AFTER authenticating the client, so an unauthenticated caller
+  // observes `invalid_client` before any catalog error.
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
     return reject.into_response();
   }
@@ -485,16 +487,6 @@ pub async fn handler(
     ))
     .into_response();
   }
-  let aud = match crate::protocol::params::validate_resources(
-    &state.pool,
-    state.config.resource_indicators_enabled,
-    &resources,
-  )
-  .await
-  {
-    Ok(aud) => aud,
-    Err(error) => return TokenError::from(error).into_response(),
-  };
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let now_unix = state.clock.now().unix_timestamp();
@@ -505,7 +497,7 @@ pub async fn handler(
     headers: &headers,
     form: &form,
     peer_certs: peer_certs.as_deref(),
-    resources: &aud,
+    resources: &resources,
     cimd: state
       .config
       .cimd_enabled

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

@@ -96,6 +96,7 @@ async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
     token_format: format,
     jwt_signer: jwt,
     cimd: d.cimd,
+    resource_indicators_enabled: d.state.config.resource_indicators_enabled,
     now_unix: d.now_unix,
   };
   let inputs = CodeGrantInputs {
@@ -123,6 +124,10 @@ async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
 }
 
 /// `grant_type=refresh_token`: authenticate, then rotate with fresh proofs.
+///
+/// The presented `resource` set is validated against the catalog only after
+/// the client is authenticated, so an unauthenticated caller observes
+/// `invalid_client` before any catalog error.
 async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
@@ -131,6 +136,12 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
+  let validated = crate::protocol::params::validate_resources(
+    &d.state.pool,
+    d.state.config.resource_indicators_enabled,
+    d.resources,
+  )
+  .await?;
   let enabled = enabled_scopes(&d.state.pool).await?;
   let mut service = token_service(d.state)?;
   if format == TokenFormat::Jwt {
@@ -156,12 +167,13 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
       dpop_required: client.dpop_bound_access_tokens,
       refresh_token: d.form.refresh_token.as_deref().unwrap_or(""),
       requested_scope: d.form.scope.as_deref(),
-      requested_resources: d.resources,
+      requested_resources: &validated,
       proof: &proof,
       enabled: &enabled,
       client_registered: &registered,
       user_entitled: &no_user_term,
       token_format: format,
+      resource_indicators_enabled: d.state.config.resource_indicators_enabled,
       now_unix: d.now_unix,
     },
   )
@@ -177,10 +189,20 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
 }
 
 /// `grant_type=client_credentials`: confidential clients only, access-only.
+///
+/// The presented `resource` set is validated against the catalog only after
+/// the client is authenticated, so an unauthenticated caller observes
+/// `invalid_client` before any catalog error.
 async fn client_credentials(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   require_grant(&client, client_credentials::GRANT_TYPE_CLIENT_CREDENTIALS)?;
+  let validated = crate::protocol::params::validate_resources(
+    &d.state.pool,
+    d.state.config.resource_indicators_enabled,
+    d.resources,
+  )
+  .await?;
   let format = d.require_token_format(&client)?;
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
@@ -204,7 +226,7 @@ async fn client_credentials(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
       dpop_bound_jkt: None,
       mtls_bound_x5t_s256: client.tls_client_auth_cert_thumbprint.as_deref(),
       proof: &proof,
-      aud: d.resources,
+      aud: &validated,
       token_format: format,
       now_unix: d.now_unix,
     },
@@ -255,6 +277,7 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
     token_format: format,
     jwt_signer: jwt,
     cimd: d.cimd,
+    resource_indicators_enabled: d.state.config.resource_indicators_enabled,
     now_unix: d.now_unix,
   };
   let inputs = DevicePollInputs {

+ 162 - 7
src/protocol/token/grants/authorization_code.rs

@@ -68,6 +68,10 @@ pub struct CodeGrantState<'a> {
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off.
   pub cimd: Option<cimd::CimdSettings<'a>>,
+  /// Config `resource_indicators_enabled`: the presented `resource` set is
+  /// validated against the catalog after authentication and before the code
+  /// is consumed, so a bad value fails `invalid_target` without burning it.
+  pub resource_indicators_enabled: bool,
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
 }
@@ -94,9 +98,10 @@ pub struct CodeGrantInputs<'a> {
   pub dpop_proof: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
-  /// Validated resource audiences (RFC 8707) presented with this token
-  /// request, sorted and deduped by the handler; empty when the request
-  /// carried no `resource`. Must equal the set pinned into the code.
+  /// Raw repeated `resource` values (RFC 8707) presented with this token
+  /// request, in wire order; empty when the request carried no `resource`.
+  /// Validated against the catalog after authentication (before the code is
+  /// consumed); the validated set must equal the set pinned into the code.
   pub aud: &'a [String],
 }
 
@@ -119,7 +124,9 @@ pub struct CodeTokenResponse {
 
 /// Redeem an authorization code at the token endpoint (RFC 9700 §4.5.3).
 ///
-/// The client is authenticated, then the code is consumed atomically bound to
+/// The client is authenticated and its sender proofs verified first, then the
+/// presented resource set is catalog-validated (`invalid_target` while the
+/// code stays live), and only then is the code consumed atomically bound to
 /// the expected client id, redirect URI, and PKCE challenge: a foreign or
 /// mismatched presentation observes `Mismatched` and the victim record
 /// survives for its legitimate presenter, so a wrong, expired, or
@@ -189,6 +196,23 @@ pub async fn redeem_code(
     .ok_or_else(|| AppError::invalid_grant("code_verifier does not match code_challenge"))?;
   let expected_challenge = pkce::challenge_from_verifier(verifier)
     .map_err(|_| AppError::invalid_grant("code_verifier does not match code_challenge"))?;
+
+  // Authentication and sender-constraint checks run BEFORE the code is
+  // consumed, so a wrong client, a bad DPoP proof, or a catalog failure
+  // never burns a live code.
+  let proofs = fresh_proofs(state, &client, inputs, used_basic).await?;
+  require_dpop_binding(&client, &proofs)?;
+
+  // RFC 8707 §2: the presented set is catalog-validated before the consume,
+  // so a disabled flag, a non-`https` value, or an unknown/disabled member
+  // fails `invalid_target` while the code stays live for a retry.
+  let validated = crate::protocol::params::validate_resources(
+    state.pool,
+    state.resource_indicators_enabled,
+    inputs.aud,
+  )
+  .await?;
+
   let record = consume_code(
     state,
     inputs.code.trim(),
@@ -202,7 +226,7 @@ pub async fn redeem_code(
   // the authorization request pinned into the code. The code was consumed
   // above (single use), so a mismatch is terminal for it: fail closed with
   // `invalid_grant`, never mint.
-  if !crate::protocol::params::audience_binds(&record.aud, inputs.aud) {
+  if !crate::protocol::params::audience_binds(&record.aud, &validated) {
     return Err(AppError::invalid_grant(
       "resource indicators do not match this authorization code",
     ));
@@ -217,8 +241,6 @@ pub async fn redeem_code(
     ));
   }
 
-  let proofs = fresh_proofs(state, &client, inputs, used_basic).await?;
-  require_dpop_binding(&client, &proofs)?;
   if let Some(want) = non_empty(&record.cnf) {
     if proofs.dpop_jkt.as_deref().unwrap_or("") != want {
       return Err(AppError::invalid_grant(
@@ -721,6 +743,7 @@ mod tests {
       token_format: TokenFormat::Opaque,
       jwt_signer: None,
       cimd: None,
+      resource_indicators_enabled: true,
       now_unix: now,
     };
     super::redeem_code(&mut state, inputs).await
@@ -1166,6 +1189,66 @@ mod tests {
     Ok(())
   }
 
+  /// Authentication precedes catalog validation: a wrong secret with an
+  /// unknown `resource` observes `invalid_client`, never `invalid_target`,
+  /// and the code stays live for the legitimate retry.
+  #[tokio::test]
+  async fn code_wrong_secret_with_unknown_resource_is_invalid_client() -> TestResult {
+    let mut h = harness().await?;
+    let client_id = unique("auth-first");
+    insert_client(
+      &h,
+      &client_id,
+      TokenEndpointAuthMethod::ClientSecretPost,
+      Some("code-secret"),
+      &[super::GRANT_TYPE_AUTHORIZATION_CODE],
+    )
+    .await?;
+    let issued = issue_code(&mut h, &client_id, CodeClientKind::Confidential, None, &[]).await?;
+    let now = now_unix();
+
+    let ghost = vec!["https://ghost.example.test/".to_owned()];
+    let denied = CodeGrantInputs {
+      code: issued.code.as_str(),
+      redirect_uri: REDIRECT,
+      code_verifier: Some(VERIFIER),
+      client_id: Some(client_id.as_str()),
+      basic_header: None,
+      body_secret: Some(secret("wrong-secret")),
+      assertion_type: None,
+      assertion: None,
+      dpop_proof: None,
+      peer_certs: None,
+      aud: &ghost,
+    };
+    match redeem_once(&mut h, &denied, now).await {
+      Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidClient => {}
+      Err(other) => {
+        return Err(TestError::Unexpected(format!(
+          "bad credentials must be invalid_client, got {other}"
+        )));
+      }
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "bad credentials must never mint".to_owned(),
+        ));
+      }
+    }
+    // The failed attempt must not burn the code: the legitimate presenter
+    // redeems the same value.
+    let retry = CodeGrantInputs {
+      body_secret: Some(secret("code-secret")),
+      aud: &[],
+      ..inputs_clone(&denied)
+    };
+    redeem_once(&mut h, &retry, now + 1)
+      .await
+      .map_err(|error| {
+        TestError::Unexpected(format!("legitimate retry burned by bad auth: {error}"))
+      })?;
+    Ok(())
+  }
+
   #[tokio::test]
   async fn test_grant_code_wrong_verifier_rejected() -> TestResult {
     let mut h = harness().await?;
@@ -1425,6 +1508,7 @@ mod tests {
         client_registered: &enabled,
         user_entitled: &enabled,
         token_format: TokenFormat::Opaque,
+        resource_indicators_enabled: false,
         now_unix: now + 1,
       },
     )
@@ -1575,6 +1659,9 @@ mod tests {
       &[super::GRANT_TYPE_AUTHORIZATION_CODE],
     )
     .await?;
+    for value in [AUD_ONE, AUD_TWO, AUD_OTHER] {
+      resource_repo::upsert(&h.pool, value, "audience pinning test").await?;
+    }
     let pinned = vec![AUD_ONE.to_owned(), AUD_TWO.to_owned()];
     let now = now_unix();
 
@@ -1659,6 +1746,70 @@ mod tests {
     expect_audience_rejected(&mut h, &code_inputs(&issued, &client_id, &[]), now_unix()).await
   }
 
+  /// A code pinned to a resource that is disabled after issuance fails
+  /// `invalid_target` WITHOUT consuming the code: re-enabling the row lets
+  /// the same code redeem.
+  #[tokio::test]
+  async fn code_with_disabled_resource_is_invalid_target_without_consuming() -> TestResult {
+    let mut h = harness().await?;
+    let client_id = unique("stale-audience");
+    insert_client(
+      &h,
+      &client_id,
+      TokenEndpointAuthMethod::ClientSecretPost,
+      Some("code-secret"),
+      &[super::GRANT_TYPE_AUTHORIZATION_CODE],
+    )
+    .await?;
+    resource_repo::upsert(&h.pool, AUD_ONE, "stale audience test").await?;
+    let pinned = vec![AUD_ONE.to_owned()];
+    let issued = issue_code(
+      &mut h,
+      &client_id,
+      CodeClientKind::Confidential,
+      None,
+      &pinned,
+    )
+    .await?;
+    if !resource_repo::disable(&h.pool, AUD_ONE).await? {
+      return Err(TestError::Unexpected(
+        "disable must update the seeded row".to_owned(),
+      ));
+    }
+    let now = now_unix();
+
+    let inputs = code_inputs(&issued, &client_id, &pinned);
+    match redeem_once(&mut h, &inputs, now).await {
+      Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidTarget => {}
+      Err(other) => {
+        return Err(TestError::Unexpected(format!(
+          "disabled audience must be invalid_target, got {other}"
+        )));
+      }
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "a disabled audience must never mint".to_owned(),
+        ));
+      }
+    }
+
+    // The failed redemption must not have burned the code: re-enable the row
+    // and redeem the same value.
+    sqlx::query("UPDATE resource_servers SET enabled = true WHERE identifier = $1")
+      .bind(AUD_ONE)
+      .execute(&h.pool)
+      .await?;
+    let tokens = redeem_once(&mut h, &code_inputs(&issued, &client_id, &pinned), now + 1)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("re-enabled redemption rejected: {error}")))?;
+    if tokens.access_token.is_empty() {
+      return Err(TestError::Unexpected(
+        "re-enabled redemption must mint an access token".to_owned(),
+      ));
+    }
+    Ok(())
+  }
+
   /// Assert an audience mismatch fails as `invalid_grant` and never mints.
   async fn expect_audience_rejected(
     h: &mut Harness,
@@ -1879,6 +2030,7 @@ mod tests {
     let approved = ScopeSet::parse("orders:read")?;
     let entitled = ScopeSet::parse("orders:read orders:admin")?;
     let pinned = vec![AUD_ONE.to_owned()];
+    resource_repo::upsert(&h.pool, AUD_ONE, "jwt audience").await?;
     let issued = issuer
       .issue(
         &mut h.conn,
@@ -1933,6 +2085,7 @@ mod tests {
       token_format: TokenFormat::Jwt,
       jwt_signer: Some(signer.clone()),
       cimd: None,
+      resource_indicators_enabled: true,
     };
     let tokens = super::redeem_code(&mut state, &inputs)
       .await
@@ -2041,6 +2194,7 @@ mod tests {
         token_format: TokenFormat::Jwt,
         jwt_signer: Some(signer.clone()),
         cimd: None,
+        resource_indicators_enabled: false,
       };
       match super::redeem_code(&mut state, &inputs).await {
         Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidTarget => {}
@@ -2070,6 +2224,7 @@ mod tests {
       token_format: TokenFormat::Jwt,
       jwt_signer: Some(signer),
       cimd: None,
+      resource_indicators_enabled: false,
     };
     match super::redeem_code(&mut retry, &inputs).await {
       Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidGrant => Ok(()),

+ 21 - 5
src/protocol/token/grants/device_code.rs

@@ -73,6 +73,10 @@ pub struct PollState<'a> {
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off.
   pub cimd: Option<crate::protocol::cimd::CimdSettings<'a>>,
+  /// Config `resource_indicators_enabled`: the presented `resource` set is
+  /// catalog-validated after authentication and before any device state is
+  /// read, so a bad value fails `invalid_target` without touching the grant.
+  pub resource_indicators_enabled: bool,
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
 }
@@ -95,10 +99,11 @@ pub struct DevicePollInputs<'a> {
   pub dpop_proof: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
-  /// Validated resource audiences (RFC 8707 §2) presented with this poll,
-  /// sorted and deduped by the token handler; empty when the poll carried no
-  /// `resource`. Must equal the set pinned at authorization time (or be
-  /// inherited when empty: RFC 8707 §2.2).
+  /// Raw repeated `resource` values (RFC 8707 §2) presented with this poll,
+  /// in wire order; empty when the poll carried no `resource`. Validated
+  /// against the catalog after authentication; the validated set must equal
+  /// the set pinned at authorization time (or be inherited when empty:
+  /// RFC 8707 §2.2).
   pub aud: &'a [String],
 }
 
@@ -167,6 +172,17 @@ pub async fn poll_device(
   let proofs = proofs::fresh_proofs(state, &client, inputs).await?;
   require_dpop_binding(&client, &proofs)?;
 
+  // RFC 8707 §2: the presented set is catalog-validated after authentication
+  // and before any device state is read, so a disabled flag, a non-`https`
+  // value, or an unknown/disabled member fails `invalid_target` without
+  // touching the grant.
+  let validated = crate::protocol::params::validate_resources(
+    state.pool,
+    state.resource_indicators_enabled,
+    inputs.aud,
+  )
+  .await?;
+
   let device_hash = hash_token(state.keys, inputs.device_code.trim())?;
   let record = device_store::poll(state.conn, state.store, &device_hash)
     .await
@@ -194,7 +210,7 @@ pub async fn poll_device(
           kind,
           dpop_required: client.dpop_bound_access_tokens,
           proofs,
-          aud: inputs.aud,
+          aud: &validated,
           token_format: state.token_format,
           jwt_signer: state.jwt_signer.clone(),
         },

+ 15 - 4
src/protocol/token/grants/device_code/issue.rs

@@ -84,10 +84,7 @@ pub(super) async fn issue_approved(
   // re-sends `resource`, so an empty presentation inherits `consumed.aud`;
   // an empty pinned set keeps the unchanged no-audience behavior. A mismatch
   // is a grant-binding failure (`invalid_grant`), never `invalid_target`: the
-  // request values were already catalog-validated by the token handler.
-  //
-  // Todo 11 binds the enforced set (`consumed.aud`) through `MintRequest.aud`;
-  // nothing is written onto the token record here.
+  // request values were already catalog-validated after authentication.
   if !redemption.aud.is_empty()
     && !crate::protocol::params::audience_binds(&consumed.aud, redemption.aud)
   {
@@ -95,6 +92,20 @@ pub(super) async fn issue_approved(
       AppError::invalid_grant("resource indicators do not match this device authorization").into(),
     );
   }
+  // The pinned audience is re-validated against the CURRENT catalog before
+  // minting: a resource server disabled or deleted after authorization (or a
+  // flag turned off since) fails `invalid_target` instead of minting a token
+  // for a dead audience. The device record above is already consumed (single
+  // redemption), so a retry observes `expired_token` and the client must
+  // re-authorize; no token is minted on this path.
+  if !consumed.aud.is_empty() {
+    crate::protocol::params::validate_resources(
+      state.pool,
+      state.resource_indicators_enabled,
+      &consumed.aud,
+    )
+    .await?;
+  }
   // The device record above is already consumed (single redemption): a JWT
   // redemption with an unbound audience fails here, before the mint, and the
   // client must re-authorize with a `resource`.

+ 12 - 0
src/protocol/token/grants/device_code/tests/flows.rs

@@ -62,6 +62,17 @@ pub(super) async fn poll_once(
   h: &mut Harness,
   inputs: &DevicePollInputs<'_>,
   now: i64,
+) -> Result<DeviceTokenResponse, DevicePollError> {
+  poll_once_with_flag(h, inputs, now, true).await
+}
+
+/// Poll with an explicit `resource_indicators_enabled` flag, for the stale
+/// catalog and flag-off negatives.
+pub(super) async fn poll_once_with_flag(
+  h: &mut Harness,
+  inputs: &DevicePollInputs<'_>,
+  now: i64,
+  resource_indicators_enabled: bool,
 ) -> Result<DeviceTokenResponse, DevicePollError> {
   let mut state = PollState {
     pool: &h.pool,
@@ -75,6 +86,7 @@ pub(super) async fn poll_once(
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     cimd: None,
+    resource_indicators_enabled,
     now_unix: now,
   };
   poll_device(&mut state, inputs).await

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

@@ -215,6 +215,7 @@ async fn test_grant_device_confidential_secret_redeems_bearer() -> TestResult {
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t0 + 6,
   };
   let tokens = poll_device(&mut state, &poll_inputs)
@@ -289,6 +290,7 @@ async fn test_grant_device_dpop_bound_confidential_requires_fresh_proof() -> Tes
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t0 + 6,
   };
   match poll_device(&mut state, &poll_inputs).await {

+ 2 - 0
src/protocol/token/grants/device_code/tests/poll.rs

@@ -216,6 +216,7 @@ async fn test_grant_device_concurrent_poll_mints_once() -> TestResult {
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t1,
   };
   let mut right_state = PollState {
@@ -230,6 +231,7 @@ async fn test_grant_device_concurrent_poll_mints_once() -> TestResult {
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t1,
   };
   let (left, right) = tokio::join!(

+ 2 - 0
src/protocol/token/grants/device_code/tests/race.rs

@@ -41,6 +41,7 @@ async fn test_grant_device_concurrent_interval_admits_only_one() -> TestResult {
       token_format: TokenFormat::Opaque,
       jwt_signer: None,
       cimd: None,
+      resource_indicators_enabled: false,
       now_unix: now,
     };
     let mut right = PollState {
@@ -55,6 +56,7 @@ async fn test_grant_device_concurrent_interval_admits_only_one() -> TestResult {
       token_format: TokenFormat::Opaque,
       jwt_signer: None,
       cimd: None,
+      resource_indicators_enabled: false,
       now_unix: now,
     };
     let (left_outcome, right_outcome) = tokio::join!(

+ 145 - 4
src/protocol/token/grants/device_code/tests/resource.rs

@@ -9,7 +9,9 @@
 use uuid::Uuid;
 
 use super::super::{DevicePollError, DevicePollInputs};
-use super::flows::{authorize_with_resources, decide, now_unix, poll_once, public_inputs};
+use super::flows::{
+  authorize_with_resources, decide, now_unix, poll_once, poll_once_with_flag, public_inputs,
+};
 use super::setup;
 use super::{TestError, TestResult, harness, unique};
 use crate::{
@@ -288,12 +290,111 @@ async fn device_poll_rejects_different_resource() -> TestResult {
   Ok(())
 }
 
-/// Authorization-side negatives: the flag gate and the catalog/scheme checks
-/// fail closed with `invalid_target` before any device record is written.
+/// A resource server disabled after authorization rejects the inheriting
+/// poll with `invalid_target` before minting: the grant is already consumed,
+/// so a retry observes `expired_token` and no token is ever issued for the
+/// dead audience.
+#[tokio::test]
+async fn device_poll_rejects_disabled_audience_without_minting() -> TestResult {
+  let mut h = harness().await?;
+  seed_catalog(&mut h, &[RES_A]).await?;
+  let (client_id, user_id) = public_client(&h).await?;
+  let t0 = now_unix();
+  let requested = vec![RES_A.to_owned()];
+  let issued = issue_approved(&mut h, &client_id, &user_id, &requested, t0).await?;
+  if !resource_repo::disable(&h.pool, RES_A).await? {
+    return Err(TestError::Unexpected(
+      "disable must update the seeded row".to_owned(),
+    ));
+  }
+
+  let (access_before, refresh_before) = minted_counts(&mut h).await?;
+  let t1 = t0 + crate::protocol::device::DEVICE_INTERVAL_SECS as i64 + 1;
+  match poll_once(&mut h, &inputs(&client_id, &issued.device_code, &[]), t1).await {
+    Err(DevicePollError::Protocol(error)) if is_invalid_target(&error) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "disabled audience must be invalid_target, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a disabled audience must never mint".to_owned(),
+      ));
+    }
+  }
+  let (access_after, refresh_after) = minted_counts(&mut h).await?;
+  if (access_before, refresh_before) != (access_after, refresh_after) {
+    return Err(TestError::Unexpected(
+      "a stale poll must not mint any token".to_owned(),
+    ));
+  }
+  // The grant was consumed by the failed poll: a retry is expired.
+  let t2 = t1 + crate::protocol::device::DEVICE_INTERVAL_SECS as i64 + 1;
+  match poll_once(&mut h, &inputs(&client_id, &issued.device_code, &[]), t2).await {
+    Err(DevicePollError::ExpiredToken) => Ok(()),
+    Err(other) => Err(TestError::Unexpected(format!(
+      "a consumed grant must be expired_token, got {}",
+      other.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "a consumed grant must never mint".to_owned(),
+    )),
+  }
+}
+
+/// The flag turned off since authorization rejects the inheriting poll the
+/// same way, even though the catalog row is still enabled.
+#[tokio::test]
+async fn device_poll_rejects_stale_audience_when_flag_off() -> TestResult {
+  let mut h = harness().await?;
+  seed_catalog(&mut h, &[RES_A]).await?;
+  let (client_id, user_id) = public_client(&h).await?;
+  let t0 = now_unix();
+  let requested = vec![RES_A.to_owned()];
+  let issued = issue_approved(&mut h, &client_id, &user_id, &requested, t0).await?;
+
+  let (access_before, refresh_before) = minted_counts(&mut h).await?;
+  let t1 = t0 + crate::protocol::device::DEVICE_INTERVAL_SECS as i64 + 1;
+  match poll_once_with_flag(
+    &mut h,
+    &inputs(&client_id, &issued.device_code, &[]),
+    t1,
+    false,
+  )
+  .await
+  {
+    Err(DevicePollError::Protocol(error)) if is_invalid_target(&error) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "flag-off audience must be invalid_target, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a flag-off audience must never mint".to_owned(),
+      ));
+    }
+  }
+  let (access_after, refresh_after) = minted_counts(&mut h).await?;
+  if (access_before, refresh_before) != (access_after, refresh_after) {
+    return Err(TestError::Unexpected(
+      "a stale poll must not mint any token".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Authorization-side negatives: once the caller is authenticated, the flag
+/// gate and the catalog/scheme checks fail closed with `invalid_target`
+/// before any device record is written.
 #[tokio::test]
 async fn device_authorization_rejects_resource_without_catalog() -> TestResult {
   let mut h = harness().await?;
   seed_catalog(&mut h, &[RES_A]).await?;
+  let (client_id, _) = public_client(&h).await?;
 
   let valid = vec![RES_A.to_owned()];
   let unknown = vec![RES_C.to_owned()];
@@ -305,7 +406,7 @@ async fn device_authorization_rejects_resource_without_catalog() -> TestResult {
     ("non-https identifier", insecure.as_slice(), true),
   ] {
     let inputs = DeviceHttpInputs {
-      client_id: "resource-negatives",
+      client_id: client_id.as_str(),
       scope: None,
       basic_header: None,
       body_secret: None,
@@ -336,3 +437,43 @@ async fn device_authorization_rejects_resource_without_catalog() -> TestResult {
   }
   Ok(())
 }
+
+/// Authentication runs before catalog validation: an unknown client
+/// presenting an unknown `resource` observes `invalid_client`, never
+/// `invalid_target`, and no device record is written.
+#[tokio::test]
+async fn device_authorization_rejects_unknown_client_before_catalog() -> TestResult {
+  let mut h = harness().await?;
+  seed_catalog(&mut h, &[RES_A]).await?;
+  let unknown = vec![RES_C.to_owned()];
+  let inputs = DeviceHttpInputs {
+    client_id: "ghost-client",
+    scope: None,
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: None,
+    resource: unknown.as_slice(),
+    peer_certs: None,
+  };
+  match authorize_with_resources(&mut h, &inputs, now_unix(), true).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidClient => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "unknown client must be invalid_client, got {other}"
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "an unknown client must never issue a device code".to_owned(),
+      ));
+    }
+  }
+  if device_keys(&mut h).await? != 0 {
+    return Err(TestError::Unexpected(
+      "a rejected authorization must not write a device record".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 25 - 5
src/protocol/token/grants/refresh_token.rs

@@ -60,12 +60,12 @@ pub struct RefreshInput<'a> {
   pub refresh_token: &'a str,
   /// Raw `scope` parameter, or `None` when omitted (keeps the grant scope).
   pub requested_scope: Option<&'a str>,
-  /// Validated RFC 8707 §2 `resource` set for this request, in the handler's
+  /// Catalog-validated RFC 8707 §2 `resource` set for this request, in
   /// sorted deduped order; empty when the request carried no `resource`
   /// (RFC 8707 §2.2: the grant keeps its full original audience, NOT §3's
-  /// empty set). The token handler already rejected a disabled gate, a
-  /// non-`https` value, and any value outside the enabled catalog with
-  /// `invalid_target`, so this set is catalog-bounded on entry.
+  /// empty set). The dispatcher validates the presented set against the
+  /// catalog after client authentication, so this set is catalog-bounded on
+  /// entry.
   pub requested_resources: &'a [String],
   /// Freshly verified proofs for this request (never a stored binding).
   pub proof: &'a SenderProof,
@@ -82,6 +82,11 @@ pub struct RefreshInput<'a> {
   /// today's behavior; `Jwt` returns a signed RFC 9068 `at+jwt` whose `aud`
   /// claim is the narrowed access set. The refresh half stays opaque.
   pub token_format: TokenFormat,
+  /// Config `resource_indicators_enabled`: the grant's stored audience is
+  /// re-validated against the CURRENT catalog before rotation, so a
+  /// disabled/deleted member (or a flag turned off since issuance) fails
+  /// `invalid_target` while the presented refresh token stays live.
+  pub resource_indicators_enabled: bool,
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
 }
@@ -128,7 +133,9 @@ pub struct RefreshUserStores<'a> {
 /// replacement refresh token (draft-ietf-oauth-v2-1-16 §4.3.3), so a revoked
 /// entitlement or a removed client scope drops out on refresh instead of
 /// surviving for the token's whole lifetime while an explicit narrower
-/// request narrows only the access token (§4.3.1). The disabled check preserves unknown-token,
+/// request narrows only the access token (§4.3.1). The grant's stored
+/// audience is likewise re-checked against the current catalog before
+/// rotation (`invalid_target`, token stays live). The disabled check preserves unknown-token,
 /// tombstone-cascade, and proof-check behavior. A datastore failure propagates
 /// as an error.
 ///
@@ -155,6 +162,18 @@ pub async fn refresh_with_subject_check(
   let Some(found) = found else {
     return refresh(conn, store, service, input).await;
   };
+  // The stored audience is re-validated against the CURRENT catalog before
+  // anything is minted or rotated: a resource server disabled or deleted
+  // after issuance (or a flag turned off since) fails `invalid_target` while
+  // the presented refresh token stays live for a retry.
+  if !found.record.aud.is_empty() {
+    crate::protocol::params::validate_resources(
+      users.pool,
+      input.resource_indicators_enabled,
+      &found.record.aud,
+    )
+    .await?;
+  }
   if !found.record.subject.trim().is_empty()
     && matches!(
       user_repo::subject_account_status(users.pool, users.keys, &found.record.subject).await?,
@@ -180,6 +199,7 @@ pub async fn refresh_with_subject_check(
     client_registered: input.client_registered,
     user_entitled: &entitled,
     token_format: input.token_format,
+    resource_indicators_enabled: input.resource_indicators_enabled,
     now_unix: input.now_unix,
   };
   refresh(conn, store, service, &fresh).await

+ 2 - 0
src/protocol/token/grants/refresh_token/tests.rs

@@ -15,6 +15,7 @@ mod recovery;
 mod reuse;
 mod rotation;
 mod scope;
+mod stale_audience;
 
 use std::{
   sync::atomic::{AtomicU64, Ordering},
@@ -213,6 +214,7 @@ fn input<'a>(
     refresh_token: token,
     requested_scope: scope,
     requested_resources: &[],
+    resource_indicators_enabled: false,
     proof,
     enabled,
     client_registered: enabled,

+ 1 - 0
src/protocol/token/grants/refresh_token/tests/audience.rs

@@ -87,6 +87,7 @@ fn aud_input<'a>(
     client_registered: enabled,
     user_entitled: enabled,
     token_format: TokenFormat::Opaque,
+    resource_indicators_enabled: false,
     now_unix: now_unix(),
   }
 }

+ 1 - 0
src/protocol/token/grants/refresh_token/tests/entitlements.rs

@@ -120,6 +120,7 @@ fn entitled_input<'a>(
     refresh_token: token,
     requested_scope: scope,
     requested_resources: &[],
+    resource_indicators_enabled: false,
     proof,
     enabled,
     client_registered: registered,

+ 1 - 0
src/protocol/token/grants/refresh_token/tests/scope.rs

@@ -235,6 +235,7 @@ async fn grant_refresh_narrow_drops_revoked_from_refresh_scope() -> TestResult {
       refresh_token: &minted.refresh_token,
       requested_scope: Some("read"),
       requested_resources: &[],
+      resource_indicators_enabled: false,
       proof: &proof,
       enabled: &enabled,
       client_registered: &registered,

+ 277 - 0
src/protocol/token/grants/refresh_token/tests/stale_audience.rs

@@ -0,0 +1,277 @@
+//! Stored-audience re-validation on refresh (RFC 8707 staleness).
+//!
+//! The dispatcher validates the PRESENTED set after authentication, while
+//! [`refresh_with_subject_check`](super::super::refresh_with_subject_check)
+//! re-validates the grant's STORED audience against the CURRENT catalog
+//! before rotation. A resource server disabled after issuance (or the flag
+//! turned off since) fails `invalid_target` with the presented refresh token
+//! left live for a retry; an enabled catalog keeps the original audience on
+//! both rotated records.
+
+use super::{TestError, TestResult, dpop_proof, now_unix, ring, service, test_redis, unique};
+use crate::{
+  db::{resource_repo, test_support::migrated_pool},
+  error::OAuthErrorCode,
+  protocol::{
+    scope::ScopeSet,
+    token::{
+      ClientKind, MintRequest, MintedTokens, SenderProof, TokenService,
+      grants::refresh_token::{RefreshInput, RefreshUserStores, refresh_with_subject_check},
+    },
+  },
+  state::RedisConn,
+  store::Store,
+};
+
+/// Resource indicator pinned into the test grants (RFC 8707).
+const RES_A: &str = "https://rs-stale.example.test/api";
+
+/// Whether the rejection is `invalid_target`.
+fn is_invalid_target(error: &crate::error::AppError) -> bool {
+  error.code() == OAuthErrorCode::InvalidTarget
+}
+
+/// Mint one refreshable grant whose records carry `aud` on both halves and
+/// whose subject is empty (a bearer-style principal, so no user lookup runs).
+async fn mint_aud_grant(
+  service: &TokenService,
+  conn: &mut RedisConn,
+  store: &Store,
+  client: &str,
+  proof: SenderProof,
+  aud: &[String],
+) -> Result<MintedTokens, TestError> {
+  let minted = service
+    .mint(
+      conn,
+      store,
+      &MintRequest {
+        client_id: client.to_owned(),
+        client_kind: ClientKind::Public,
+        dpop_required: true,
+        subject: String::new(),
+        scope: "read".to_owned(),
+        access_scope: "read".to_owned(),
+        grant_id: unique("stale-grant"),
+        proof,
+        aud: aud.to_vec(),
+        refresh_aud: aud.to_vec(),
+        format: super::super::TokenFormat::Opaque,
+      },
+      now_unix(),
+    )
+    .await?;
+  Ok(minted)
+}
+
+/// A resource disabled after issuance rejects the resourceless refresh with
+/// `invalid_target` BEFORE rotation, leaving the presented token live.
+#[tokio::test]
+async fn refresh_rejects_disabled_audience_and_keeps_token_live() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("pg harness: {error}")))?;
+  let keys = ring()?;
+  let service = service()?;
+  resource_repo::upsert(&pool, RES_A, "stale audience test").await?;
+  let client = unique("stale-client");
+  let proof = dpop_proof(&unique("stale-jkt"));
+  let scoped = ScopeSet::parse("read")?;
+  let minted = mint_aud_grant(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    proof.clone(),
+    &[RES_A.to_owned()],
+  )
+  .await?;
+  if !resource_repo::disable(&pool, RES_A).await? {
+    return Err(TestError::Unexpected(
+      "disable must update the seeded row".to_owned(),
+    ));
+  }
+
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+  let input = RefreshInput {
+    client_id: &client,
+    client_kind: ClientKind::Public,
+    dpop_required: true,
+    refresh_token: &minted.refresh_token,
+    requested_scope: None,
+    requested_resources: &[],
+    proof: &proof,
+    enabled: &scoped,
+    client_registered: &scoped,
+    user_entitled: &scoped,
+    token_format: super::super::TokenFormat::Opaque,
+    resource_indicators_enabled: true,
+    now_unix: now_unix(),
+  };
+  match refresh_with_subject_check(&mut conn, &store, &service, &users, &input).await {
+    Err(error) if is_invalid_target(&error) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "disabled audience must be invalid_target, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a disabled audience must never rotate".to_owned(),
+      ));
+    }
+  }
+  // Nothing was consumed or rotated: the presented token still works.
+  if service
+    .find_refresh(&mut conn, &store, &minted.refresh_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "the rejected refresh token was consumed".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// The flag turned off since issuance rejects the resourceless refresh the
+/// same way, even though the catalog row is still enabled.
+#[tokio::test]
+async fn refresh_rejects_stale_audience_when_flag_off() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("pg harness: {error}")))?;
+  let keys = ring()?;
+  let service = service()?;
+  resource_repo::upsert(&pool, RES_A, "stale audience test").await?;
+  let client = unique("stale-flag-client");
+  let proof = dpop_proof(&unique("stale-flag-jkt"));
+  let scoped = ScopeSet::parse("read")?;
+  let minted = mint_aud_grant(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    proof.clone(),
+    &[RES_A.to_owned()],
+  )
+  .await?;
+
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+  let input = RefreshInput {
+    client_id: &client,
+    client_kind: ClientKind::Public,
+    dpop_required: true,
+    refresh_token: &minted.refresh_token,
+    requested_scope: None,
+    requested_resources: &[],
+    proof: &proof,
+    enabled: &scoped,
+    client_registered: &scoped,
+    user_entitled: &scoped,
+    token_format: super::super::TokenFormat::Opaque,
+    resource_indicators_enabled: false,
+    now_unix: now_unix(),
+  };
+  match refresh_with_subject_check(&mut conn, &store, &service, &users, &input).await {
+    Err(error) if is_invalid_target(&error) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "flag-off audience must be invalid_target, got {}",
+        other.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a flag-off audience must never rotate".to_owned(),
+      ));
+    }
+  }
+  if service
+    .find_refresh(&mut conn, &store, &minted.refresh_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "the rejected refresh token was consumed".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Control: an enabled catalog plus an omitted `resource` still rotates and
+/// keeps the original audience on both records through the checked path.
+#[tokio::test]
+async fn refresh_with_subject_check_preserves_live_audience() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("pg harness: {error}")))?;
+  let keys = ring()?;
+  let service = service()?;
+  resource_repo::upsert(&pool, RES_A, "stale audience test").await?;
+  let client = unique("stale-control-client");
+  let proof = dpop_proof(&unique("stale-control-jkt"));
+  let scoped = ScopeSet::parse("read")?;
+  let original = vec![RES_A.to_owned()];
+  let minted = mint_aud_grant(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    proof.clone(),
+    &original,
+  )
+  .await?;
+
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+  let input = RefreshInput {
+    client_id: &client,
+    client_kind: ClientKind::Public,
+    dpop_required: true,
+    refresh_token: &minted.refresh_token,
+    requested_scope: None,
+    requested_resources: &[],
+    proof: &proof,
+    enabled: &scoped,
+    client_registered: &scoped,
+    user_entitled: &scoped,
+    token_format: super::super::TokenFormat::Opaque,
+    resource_indicators_enabled: true,
+    now_unix: now_unix(),
+  };
+  let rotated = refresh_with_subject_check(&mut conn, &store, &service, &users, &input)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("live audience refresh rejected: {error}")))?;
+  let access = service
+    .find_access(&mut conn, &store, &rotated.access_token)
+    .await?
+    .ok_or_else(|| TestError::Unexpected("rotated access token not found".to_owned()))?;
+  if access.record.aud != original {
+    return Err(TestError::Unexpected(
+      "rotated access token lost its audience".to_owned(),
+    ));
+  }
+  let refresh = service
+    .find_refresh(&mut conn, &store, &rotated.refresh_token)
+    .await?
+    .ok_or_else(|| TestError::Unexpected("rotated refresh token not found".to_owned()))?;
+  if refresh.record.aud != original {
+    return Err(TestError::Unexpected(
+      "rotated refresh token lost its audience".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 59 - 0
tests/e2e_transport.rs

@@ -318,3 +318,62 @@ async fn e2e_transport_register_malformed_json_and_missing_auth() -> Result<(),
     format!("missing auth must render invalid_client, got {body}"),
   )
 }
+
+/// Authentication precedes catalog validation on `/token`: bad credentials
+/// with an unknown `resource` observe `invalid_client` 401 (never
+/// `invalid_target`), while the same unknown `resource` with valid
+/// credentials still fails `invalid_target` 400 (the check moved, it did
+/// not disappear).
+#[tokio::test]
+async fn e2e_transport_token_auth_runs_before_catalog() -> Result<(), TestError> {
+  let world = World::start_with(
+    open_rate_limit(),
+    serde_json::json!({"resource_indicators_enabled": true}),
+  )
+  .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?;
+
+  let ghost = "https://ghost.example.test/";
+  let params = [
+    ("grant_type", "client_credentials"),
+    ("client_id", CLIENT_ID),
+    ("resource", ghost),
+  ];
+  let (status, _, body) = world
+    .post_form("/token", &params, Some((CLIENT_ID, "wrong-secret")), &[])
+    .await?;
+  ensure(
+    status == StatusCode::UNAUTHORIZED,
+    format!("bad credentials must 401, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "invalid_client",
+    format!("auth must run before the catalog check, got {body}"),
+  )?;
+  ensure(
+    !body.contains("access_token"),
+    "a rejected request must not carry an access token",
+  )?;
+
+  let (status, _, body) = world
+    .post_form("/token", &params, Some((CLIENT_ID, CLIENT_SECRET)), &[])
+    .await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("unknown resource must 400, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "invalid_target",
+    format!("the catalog check must still run after auth, got {body}"),
+  )
+}