Efren Yevale Varela 2 týždňov pred
rodič
commit
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`,
 known field is JSON `invalid_request` on `/token`, `/introspect`,
 `/device_authorization`, and `/revoke` (PAR and authorize keep last-wins
 `/device_authorization`, and `/revoke` (PAR and authorize keep last-wins
 absorption). `/token` rejects a `resource` on token exchange before any catalog
 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
 ## 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
 SHOULD-NOTs a query component and leaves the identifier semantics to the
 authorization server:
 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,
 - It must exactly match an enabled catalog row. There is no prefix, suffix,
   sub-path, case folding, or wildcard match; `*` cannot be registered.
   sub-path, case folding, or wildcard match; `*` cannot be registered.
 - The RFC 8252 §7.3 loopback exception applies only to redirect URIs. It does
 - 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). |
 | `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 /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 `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
 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`
 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
 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
 ### Refresh audience narrowing
 
 
@@ -383,6 +386,11 @@ issued with. On rotation:
 - A valid subset narrows only the access token. The replacement refresh token
 - 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
   keeps the full original set, so a later rotation can use the full audience
   again. Scope still intersects and is never widened.
   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
 ### 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
 every requested value; otherwise the response is the ordinary inactive body
 (`{"active": false}`) and the token's metadata is not leaked. Inclusion is the
 (`{"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`,
 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
 `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
 §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` |
 | Unknown or disabled catalog row | `invalid_target` |
 | Token request set differs from the pinned code or device set | `invalid_grant` |
 | 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` |
 | 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` |
 | 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`) |
 | Introspect presents `resource` the token is not bound to | Inactive body (`200`) |
 | `resource` on token exchange | `invalid_request` |
 | `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
 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`
 row alone; it never silently re-enables an operator-disabled row, and `list`
 shows both states. `delete` removes the row outright (nothing references the
 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
 `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
 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
 audience check re-opens the cross-resource replay this server's audience binding
 is meant to close.
 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`
 Last reviewed: 2026-10-21. File-mounted DB/Redis passwords, a mutable `:latest`
 image reference, NetworkPolicy, `automountServiceAccountToken: false`, scheduled
 image reference, NetworkPolicy, `automountServiceAccountToken: false`, scheduled
 fuzzing, and the CIMD outbound fetch surface are implemented in the shipped
 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
 /// The cache key for a validated document: `{state}`-prefixed
 /// `cimd:<sha256(url)>`.
 /// `cimd:<sha256(url)>`.
 ///
 ///
@@ -517,8 +564,10 @@ pub async fn fetch_and_validate(
 /// An origin-level fetch failure (`Fetch`/`InvalidJson`) is negatively cached
 /// An origin-level fetch failure (`Fetch`/`InvalidJson`) is negatively cached
 /// for 30 seconds under `cimd:neg:<sha256(url)>`. A fresh-fetch validation
 /// for 30 seconds under `cimd:neg:<sha256(url)>`. A fresh-fetch validation
 /// failure is short-cached for [`INVALID_CACHE_TTL_SECS`] under
 /// 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
 /// 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
 /// catalog changes are honored during the 15-minute TTL. If the cache write
 /// after a successful validation fails, the validated document is still
 /// 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? {
   if let Some(document) = get_cached(conn, &key).await? {
     return validate_document(client_id_url, &document, validation);
     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);
   let ttl_ms = lock_ttl_ms(limits);
@@ -587,8 +636,8 @@ pub async fn resolve(
     if let Some(document) = get_cached(conn, &key).await? {
     if let Some(document) = get_cached(conn, &key).await? {
       return validate_document(client_id_url, &document, validation);
       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
     // 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.
     // 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? {
   if let Some(document) = get_cached(conn, key).await? {
     return validate_document(client_id_url, &document, validation);
     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 {
   let document = match fetch_document(client_id_url, limits).await {
     Ok(document) => document,
     Ok(document) => document,
@@ -656,7 +705,10 @@ async fn resolve_under_lock(
   let validated = match validate_document(client_id_url, &document, validation) {
   let validated = match validate_document(client_id_url, &document, validation) {
     Ok(validated) => validated,
     Ok(validated) => validated,
     Err(error) => {
     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");
         tracing::warn!("cimd invalid-document marker write failed");
       }
       }
       return Err(error);
       return Err(error);
@@ -799,22 +851,35 @@ async fn put_negative(conn: &mut RedisConn, neg_key: &str) -> Result<(), CimdErr
     .map_err(cache_backend)
     .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)
     .arg(invalid_key)
     .query_async(conn)
     .query_async(conn)
     .await
     .await
     .map_err(cache_backend)?;
     .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")
   redis::cmd("SET")
     .arg(invalid_key)
     .arg(invalid_key)
-    .arg("1")
+    .arg(class.marker())
     .arg("EX")
     .arg("EX")
     .arg(INVALID_CACHE_TTL_SECS)
     .arg(INVALID_CACHE_TTL_SECS)
     .query_async::<()>(conn)
     .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]
 #[tokio::test]
 async fn cimd_invalid_cache_blocks_fetch() -> TestResult {
 async fn cimd_invalid_cache_blocks_fetch() -> TestResult {
   let manager = crate::test_support::redis_manager()
   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}")))?;
     .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
   let mut conn = RedisConn::Single(manager);
   let mut conn = RedisConn::Single(manager);
   let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
   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();
   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!(
       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(())
   Ok(())
 }
 }

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

@@ -367,6 +367,7 @@ async fn redeem(
     ttl: &h.ttl,
     ttl: &h.ttl,
     token_format: TokenFormat::Opaque,
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     jwt_signer: None,
+    resource_indicators_enabled: false,
     cimd: Some(CimdSettings {
     cimd: Some(CimdSettings {
       token_prefix: &h.token_prefix,
       token_prefix: &h.token_prefix,
       max_bytes: 5_120,
       max_bytes: 5_120,
@@ -573,6 +574,7 @@ async fn cimd_none_unbound_gets_bearer() -> TestResult {
       client_registered: &registered,
       client_registered: &registered,
       user_entitled: &entitled,
       user_entitled: &entitled,
       token_format: TokenFormat::Opaque,
       token_format: TokenFormat::Opaque,
+      resource_indicators_enabled: false,
       now_unix: now_unix(),
       now_unix: now_unix(),
     },
     },
   )
   )
@@ -949,6 +951,7 @@ async fn cimd_none_cannot_introspect() -> TestResult {
   let ctx = IntrospectContext {
   let ctx = IntrospectContext {
     issuer: ISSUER,
     issuer: ISSUER,
     now_unix: now_unix(),
     now_unix: now_unix(),
+    resource_indicators_enabled: false,
     cimd: Some(CimdSettings {
     cimd: Some(CimdSettings {
       token_prefix: &token_prefix,
       token_prefix: &token_prefix,
       max_bytes: 5_120,
       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() {
   if inputs.client_id.trim().is_empty() {
     return Err(AppError::invalid_request("client_id is required"));
     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 used_basic = inputs.basic_header.is_some();
   let unknown_client = || {
   let unknown_client = || {
     if used_basic {
     if used_basic {
@@ -350,6 +341,17 @@ pub async fn authorize_device(
     return Err(AppError::invalid_dpop_proof("DPoP proof is required"));
     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) {
   if !client.grant_types.iter().any(|g| g == DEVICE_GRANT) {
     return Err(AppError::unauthorized_client(
     return Err(AppError::unauthorized_client(
       "client may not use the device authorization grant",
       "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>,
   pub assertion: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
   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`
   /// 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],
   pub resource: &'a [String],
 }
 }
 
 
@@ -214,6 +215,10 @@ pub struct IntrospectContext<'a> {
   pub issuer: &'a str,
   pub issuer: &'a str,
   /// Current time as seconds since the Unix epoch.
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
   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
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off. Only `private_key_jwt` documents may
   /// `config.cimd_enabled` is off. Only `private_key_jwt` documents may
   /// introspect (RFC 7662 §2.3): a `none` principal is still rejected in
   /// 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?;
   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) {
   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 prefer_refresh = inputs.token_type_hint == Some(REFRESH_TOKEN_HINT);
   let located = locate(ctx.service, stores.conn, stores.store, raw, prefer_refresh)
   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
   // A pinned superset stays active; a missing member is indistinguishable
   // from an unknown token, so it collapses to the §2.2 inactive shape rather
   // from an unknown token, so it collapses to the §2.2 inactive shape rather
   // than an error.
   // 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());
     return Ok(IntrospectionResponse::inactive());
   }
   }
   active_response(stores, ctx, located).await
   active_response(stores, ctx, located).await
@@ -377,8 +393,8 @@ fn audience_includes(bound: &[String], requested: &[String]) -> bool {
 async fn introspect_jwt(
 async fn introspect_jwt(
   stores: &mut IntrospectStores<'_>,
   stores: &mut IntrospectStores<'_>,
   ctx: &IntrospectContext<'_>,
   ctx: &IntrospectContext<'_>,
-  inputs: &IntrospectHttpInputs<'_>,
   client: &Client,
   client: &Client,
+  validated: &[String],
   raw: &str,
   raw: &str,
 ) -> Result<IntrospectionResponse, AppError> {
 ) -> Result<IntrospectionResponse, AppError> {
   let signer = match ctx.jwt_signer {
   let signer = match ctx.jwt_signer {
@@ -408,7 +424,7 @@ async fn introspect_jwt(
   if !subject_live {
   if !subject_live {
     return Ok(IntrospectionResponse::inactive());
     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());
     return Ok(IntrospectionResponse::inactive());
   }
   }
   let token_type = if claims.cnf.as_ref().is_some_and(|cnf| cnf.jkt.is_some()) {
   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
 /// lossily accepted raw non-UTF-8 bytes); a repeated known field is JSON
 /// `invalid_request` (the old `Form` returned 422 `Failed to deserialize
 /// `invalid_request` (the old `Form` returned 422 `Failed to deserialize
 /// form body`, so the new JSON 400 is the intended contract, not parity).
 /// 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
 /// 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
 /// `revoke` and the token-exchange subject-token lookup stay
 /// audience-agnostic.
 /// audience-agnostic.
 pub async fn handler(
 pub async fn handler(
@@ -703,16 +721,6 @@ pub async fn handler(
   let form = IntrospectForm::from_map(&map);
   let form = IntrospectForm::from_map(&map);
   let resources =
   let resources =
     crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
     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
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());
     .and_then(|value| value.to_str().ok());
@@ -739,6 +747,7 @@ pub async fn handler(
   let ctx = IntrospectContext {
   let ctx = IntrospectContext {
     issuer: &state.config.issuer,
     issuer: &state.config.issuer,
     now_unix: state.clock.now().unix_timestamp(),
     now_unix: state.clock.now().unix_timestamp(),
+    resource_indicators_enabled: state.config.resource_indicators_enabled,
     cimd: state
     cimd: state
       .config
       .config
       .cimd_enabled
       .cimd_enabled

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

@@ -247,6 +247,7 @@ async fn run_core_full(
   let ctx = IntrospectContext {
   let ctx = IntrospectContext {
     issuer: &h.config.issuer,
     issuer: &h.config.issuer,
     now_unix: now_unix(),
     now_unix: now_unix(),
+    resource_indicators_enabled: true,
     cimd: None,
     cimd: None,
     service: &service,
     service: &service,
     jwt_signer: signer.as_ref(),
     jwt_signer: signer.as_ref(),
@@ -402,6 +403,28 @@ async fn test_introspect_unauthenticated_is_401() -> TestResult {
   Ok(())
   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]
 #[tokio::test]
 async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
 async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
   let mut h = harness().await?;
   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
 /// An empty input pins an empty set without touching the catalog. Otherwise
 /// the flag must be on (off + any `resource` present is `invalid_target`,
 /// 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
 /// pinning into `PendingAuth.aud` / PAR / code / device records; values are
 /// never delimiter-joined and scopes are never widened here.
 /// 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());
   let mut aud = Vec::with_capacity(distinct.len());
   for value in distinct {
   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(
       return Err(AppError::invalid_target(
         "resource must be an absolute https URI",
         "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();
     let known = resource_repo::find_enabled(pool, value).await?.is_some();
     if !known {
     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(())
   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`.
 /// Unknown and disabled catalog entries are `invalid_target`.
 #[tokio::test]
 #[tokio::test]
 async fn params_unknown_and_disabled_resources_are_invalid_target() -> TestResult {
 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,
   form: &'a TokenForm,
   /// Verified peer chain injected by the TLS accept loop, when present.
   /// Verified peer chain injected by the TLS accept loop, when present.
   peer_certs: Option<&'a PeerCertificates>,
   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],
   resources: &'a [String],
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off.
   /// `config.cimd_enabled` is off.
@@ -445,11 +447,11 @@ pub async fn handler(
   // the extractor consumed the raw body, hiding repeats. The explicit
   // the extractor consumed the raw body, hiding repeats. The explicit
   // exact-MIME gate below is the second content-type check (see above); any
   // exact-MIME gate below is the second content-type check (see above); any
   // other transport failure keeps the documented `invalid_request` mapping
   // 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) {
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
     return reject.into_response();
     return reject.into_response();
   }
   }
@@ -485,16 +487,6 @@ pub async fn handler(
     ))
     ))
     .into_response();
     .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 mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
   let now_unix = state.clock.now().unix_timestamp();
   let now_unix = state.clock.now().unix_timestamp();
@@ -505,7 +497,7 @@ pub async fn handler(
     headers: &headers,
     headers: &headers,
     form: &form,
     form: &form,
     peer_certs: peer_certs.as_deref(),
     peer_certs: peer_certs.as_deref(),
-    resources: &aud,
+    resources: &resources,
     cimd: state
     cimd: state
       .config
       .config
       .cimd_enabled
       .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,
     token_format: format,
     jwt_signer: jwt,
     jwt_signer: jwt,
     cimd: d.cimd,
     cimd: d.cimd,
+    resource_indicators_enabled: d.state.config.resource_indicators_enabled,
     now_unix: d.now_unix,
     now_unix: d.now_unix,
   };
   };
   let inputs = CodeGrantInputs {
   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.
 /// `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> {
 async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let authenticated = d.authenticate().await?;
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   let client = authenticated.client;
@@ -131,6 +136,12 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
   let kind = client_kind(&client)?;
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
   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 enabled = enabled_scopes(&d.state.pool).await?;
   let mut service = token_service(d.state)?;
   let mut service = token_service(d.state)?;
   if format == TokenFormat::Jwt {
   if format == TokenFormat::Jwt {
@@ -156,12 +167,13 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
       dpop_required: client.dpop_bound_access_tokens,
       dpop_required: client.dpop_bound_access_tokens,
       refresh_token: d.form.refresh_token.as_deref().unwrap_or(""),
       refresh_token: d.form.refresh_token.as_deref().unwrap_or(""),
       requested_scope: d.form.scope.as_deref(),
       requested_scope: d.form.scope.as_deref(),
-      requested_resources: d.resources,
+      requested_resources: &validated,
       proof: &proof,
       proof: &proof,
       enabled: &enabled,
       enabled: &enabled,
       client_registered: &registered,
       client_registered: &registered,
       user_entitled: &no_user_term,
       user_entitled: &no_user_term,
       token_format: format,
       token_format: format,
+      resource_indicators_enabled: d.state.config.resource_indicators_enabled,
       now_unix: d.now_unix,
       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.
 /// `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> {
 async fn client_credentials(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let authenticated = d.authenticate().await?;
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   let client = authenticated.client;
   require_grant(&client, client_credentials::GRANT_TYPE_CLIENT_CREDENTIALS)?;
   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 format = d.require_token_format(&client)?;
   let kind = client_kind(&client)?;
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   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,
       dpop_bound_jkt: None,
       mtls_bound_x5t_s256: client.tls_client_auth_cert_thumbprint.as_deref(),
       mtls_bound_x5t_s256: client.tls_client_auth_cert_thumbprint.as_deref(),
       proof: &proof,
       proof: &proof,
-      aud: d.resources,
+      aud: &validated,
       token_format: format,
       token_format: format,
       now_unix: d.now_unix,
       now_unix: d.now_unix,
     },
     },
@@ -255,6 +277,7 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
     token_format: format,
     token_format: format,
     jwt_signer: jwt,
     jwt_signer: jwt,
     cimd: d.cimd,
     cimd: d.cimd,
+    resource_indicators_enabled: d.state.config.resource_indicators_enabled,
     now_unix: d.now_unix,
     now_unix: d.now_unix,
   };
   };
   let inputs = DevicePollInputs {
   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
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off.
   /// `config.cimd_enabled` is off.
   pub cimd: Option<cimd::CimdSettings<'a>>,
   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.
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
   pub now_unix: i64,
 }
 }
@@ -94,9 +98,10 @@ pub struct CodeGrantInputs<'a> {
   pub dpop_proof: Option<&'a str>,
   pub dpop_proof: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
   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],
   pub aud: &'a [String],
 }
 }
 
 
@@ -119,7 +124,9 @@ pub struct CodeTokenResponse {
 
 
 /// Redeem an authorization code at the token endpoint (RFC 9700 §4.5.3).
 /// 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
 /// the expected client id, redirect URI, and PKCE challenge: a foreign or
 /// mismatched presentation observes `Mismatched` and the victim record
 /// mismatched presentation observes `Mismatched` and the victim record
 /// survives for its legitimate presenter, so a wrong, expired, or
 /// 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"))?;
     .ok_or_else(|| AppError::invalid_grant("code_verifier does not match code_challenge"))?;
   let expected_challenge = pkce::challenge_from_verifier(verifier)
   let expected_challenge = pkce::challenge_from_verifier(verifier)
     .map_err(|_| AppError::invalid_grant("code_verifier does not match code_challenge"))?;
     .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(
   let record = consume_code(
     state,
     state,
     inputs.code.trim(),
     inputs.code.trim(),
@@ -202,7 +226,7 @@ pub async fn redeem_code(
   // the authorization request pinned into the code. The code was consumed
   // the authorization request pinned into the code. The code was consumed
   // above (single use), so a mismatch is terminal for it: fail closed with
   // above (single use), so a mismatch is terminal for it: fail closed with
   // `invalid_grant`, never mint.
   // `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(
     return Err(AppError::invalid_grant(
       "resource indicators do not match this authorization code",
       "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 let Some(want) = non_empty(&record.cnf) {
     if proofs.dpop_jkt.as_deref().unwrap_or("") != want {
     if proofs.dpop_jkt.as_deref().unwrap_or("") != want {
       return Err(AppError::invalid_grant(
       return Err(AppError::invalid_grant(
@@ -721,6 +743,7 @@ mod tests {
       token_format: TokenFormat::Opaque,
       token_format: TokenFormat::Opaque,
       jwt_signer: None,
       jwt_signer: None,
       cimd: None,
       cimd: None,
+      resource_indicators_enabled: true,
       now_unix: now,
       now_unix: now,
     };
     };
     super::redeem_code(&mut state, inputs).await
     super::redeem_code(&mut state, inputs).await
@@ -1166,6 +1189,66 @@ mod tests {
     Ok(())
     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]
   #[tokio::test]
   async fn test_grant_code_wrong_verifier_rejected() -> TestResult {
   async fn test_grant_code_wrong_verifier_rejected() -> TestResult {
     let mut h = harness().await?;
     let mut h = harness().await?;
@@ -1425,6 +1508,7 @@ mod tests {
         client_registered: &enabled,
         client_registered: &enabled,
         user_entitled: &enabled,
         user_entitled: &enabled,
         token_format: TokenFormat::Opaque,
         token_format: TokenFormat::Opaque,
+        resource_indicators_enabled: false,
         now_unix: now + 1,
         now_unix: now + 1,
       },
       },
     )
     )
@@ -1575,6 +1659,9 @@ mod tests {
       &[super::GRANT_TYPE_AUTHORIZATION_CODE],
       &[super::GRANT_TYPE_AUTHORIZATION_CODE],
     )
     )
     .await?;
     .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 pinned = vec![AUD_ONE.to_owned(), AUD_TWO.to_owned()];
     let now = now_unix();
     let now = now_unix();
 
 
@@ -1659,6 +1746,70 @@ mod tests {
     expect_audience_rejected(&mut h, &code_inputs(&issued, &client_id, &[]), now_unix()).await
     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.
   /// Assert an audience mismatch fails as `invalid_grant` and never mints.
   async fn expect_audience_rejected(
   async fn expect_audience_rejected(
     h: &mut Harness,
     h: &mut Harness,
@@ -1879,6 +2030,7 @@ mod tests {
     let approved = ScopeSet::parse("orders:read")?;
     let approved = ScopeSet::parse("orders:read")?;
     let entitled = ScopeSet::parse("orders:read orders:admin")?;
     let entitled = ScopeSet::parse("orders:read orders:admin")?;
     let pinned = vec![AUD_ONE.to_owned()];
     let pinned = vec![AUD_ONE.to_owned()];
+    resource_repo::upsert(&h.pool, AUD_ONE, "jwt audience").await?;
     let issued = issuer
     let issued = issuer
       .issue(
       .issue(
         &mut h.conn,
         &mut h.conn,
@@ -1933,6 +2085,7 @@ mod tests {
       token_format: TokenFormat::Jwt,
       token_format: TokenFormat::Jwt,
       jwt_signer: Some(signer.clone()),
       jwt_signer: Some(signer.clone()),
       cimd: None,
       cimd: None,
+      resource_indicators_enabled: true,
     };
     };
     let tokens = super::redeem_code(&mut state, &inputs)
     let tokens = super::redeem_code(&mut state, &inputs)
       .await
       .await
@@ -2041,6 +2194,7 @@ mod tests {
         token_format: TokenFormat::Jwt,
         token_format: TokenFormat::Jwt,
         jwt_signer: Some(signer.clone()),
         jwt_signer: Some(signer.clone()),
         cimd: None,
         cimd: None,
+        resource_indicators_enabled: false,
       };
       };
       match super::redeem_code(&mut state, &inputs).await {
       match super::redeem_code(&mut state, &inputs).await {
         Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidTarget => {}
         Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidTarget => {}
@@ -2070,6 +2224,7 @@ mod tests {
       token_format: TokenFormat::Jwt,
       token_format: TokenFormat::Jwt,
       jwt_signer: Some(signer),
       jwt_signer: Some(signer),
       cimd: None,
       cimd: None,
+      resource_indicators_enabled: false,
     };
     };
     match super::redeem_code(&mut retry, &inputs).await {
     match super::redeem_code(&mut retry, &inputs).await {
       Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidGrant => Ok(()),
       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
   /// CIMD resolution settings for a registration-free fallback; `None` when
   /// `config.cimd_enabled` is off.
   /// `config.cimd_enabled` is off.
   pub cimd: Option<crate::protocol::cimd::CimdSettings<'a>>,
   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.
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
   pub now_unix: i64,
 }
 }
@@ -95,10 +99,11 @@ pub struct DevicePollInputs<'a> {
   pub dpop_proof: Option<&'a str>,
   pub dpop_proof: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
   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],
   pub aud: &'a [String],
 }
 }
 
 
@@ -167,6 +172,17 @@ pub async fn poll_device(
   let proofs = proofs::fresh_proofs(state, &client, inputs).await?;
   let proofs = proofs::fresh_proofs(state, &client, inputs).await?;
   require_dpop_binding(&client, &proofs)?;
   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 device_hash = hash_token(state.keys, inputs.device_code.trim())?;
   let record = device_store::poll(state.conn, state.store, &device_hash)
   let record = device_store::poll(state.conn, state.store, &device_hash)
     .await
     .await
@@ -194,7 +210,7 @@ pub async fn poll_device(
           kind,
           kind,
           dpop_required: client.dpop_bound_access_tokens,
           dpop_required: client.dpop_bound_access_tokens,
           proofs,
           proofs,
-          aud: inputs.aud,
+          aud: &validated,
           token_format: state.token_format,
           token_format: state.token_format,
           jwt_signer: state.jwt_signer.clone(),
           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`;
   // re-sends `resource`, so an empty presentation inherits `consumed.aud`;
   // an empty pinned set keeps the unchanged no-audience behavior. A mismatch
   // an empty pinned set keeps the unchanged no-audience behavior. A mismatch
   // is a grant-binding failure (`invalid_grant`), never `invalid_target`: the
   // 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()
   if !redemption.aud.is_empty()
     && !crate::protocol::params::audience_binds(&consumed.aud, redemption.aud)
     && !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(),
       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
   // The device record above is already consumed (single redemption): a JWT
   // redemption with an unbound audience fails here, before the mint, and the
   // redemption with an unbound audience fails here, before the mint, and the
   // client must re-authorize with a `resource`.
   // 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,
   h: &mut Harness,
   inputs: &DevicePollInputs<'_>,
   inputs: &DevicePollInputs<'_>,
   now: i64,
   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> {
 ) -> Result<DeviceTokenResponse, DevicePollError> {
   let mut state = PollState {
   let mut state = PollState {
     pool: &h.pool,
     pool: &h.pool,
@@ -75,6 +86,7 @@ pub(super) async fn poll_once(
     token_format: TokenFormat::Opaque,
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     jwt_signer: None,
     cimd: None,
     cimd: None,
+    resource_indicators_enabled,
     now_unix: now,
     now_unix: now,
   };
   };
   poll_device(&mut state, inputs).await
   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,
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     jwt_signer: None,
     cimd: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t0 + 6,
     now_unix: t0 + 6,
   };
   };
   let tokens = poll_device(&mut state, &poll_inputs)
   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,
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     jwt_signer: None,
     cimd: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t0 + 6,
     now_unix: t0 + 6,
   };
   };
   match poll_device(&mut state, &poll_inputs).await {
   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,
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     jwt_signer: None,
     cimd: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t1,
     now_unix: t1,
   };
   };
   let mut right_state = PollState {
   let mut right_state = PollState {
@@ -230,6 +231,7 @@ async fn test_grant_device_concurrent_poll_mints_once() -> TestResult {
     token_format: TokenFormat::Opaque,
     token_format: TokenFormat::Opaque,
     jwt_signer: None,
     jwt_signer: None,
     cimd: None,
     cimd: None,
+    resource_indicators_enabled: false,
     now_unix: t1,
     now_unix: t1,
   };
   };
   let (left, right) = tokio::join!(
   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,
       token_format: TokenFormat::Opaque,
       jwt_signer: None,
       jwt_signer: None,
       cimd: None,
       cimd: None,
+      resource_indicators_enabled: false,
       now_unix: now,
       now_unix: now,
     };
     };
     let mut right = PollState {
     let mut right = PollState {
@@ -55,6 +56,7 @@ async fn test_grant_device_concurrent_interval_admits_only_one() -> TestResult {
       token_format: TokenFormat::Opaque,
       token_format: TokenFormat::Opaque,
       jwt_signer: None,
       jwt_signer: None,
       cimd: None,
       cimd: None,
+      resource_indicators_enabled: false,
       now_unix: now,
       now_unix: now,
     };
     };
     let (left_outcome, right_outcome) = tokio::join!(
     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 uuid::Uuid;
 
 
 use super::super::{DevicePollError, DevicePollInputs};
 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::setup;
 use super::{TestError, TestResult, harness, unique};
 use super::{TestError, TestResult, harness, unique};
 use crate::{
 use crate::{
@@ -288,12 +290,111 @@ async fn device_poll_rejects_different_resource() -> TestResult {
   Ok(())
   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]
 #[tokio::test]
 async fn device_authorization_rejects_resource_without_catalog() -> TestResult {
 async fn device_authorization_rejects_resource_without_catalog() -> TestResult {
   let mut h = harness().await?;
   let mut h = harness().await?;
   seed_catalog(&mut h, &[RES_A]).await?;
   seed_catalog(&mut h, &[RES_A]).await?;
+  let (client_id, _) = public_client(&h).await?;
 
 
   let valid = vec![RES_A.to_owned()];
   let valid = vec![RES_A.to_owned()];
   let unknown = vec![RES_C.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),
     ("non-https identifier", insecure.as_slice(), true),
   ] {
   ] {
     let inputs = DeviceHttpInputs {
     let inputs = DeviceHttpInputs {
-      client_id: "resource-negatives",
+      client_id: client_id.as_str(),
       scope: None,
       scope: None,
       basic_header: None,
       basic_header: None,
       body_secret: None,
       body_secret: None,
@@ -336,3 +437,43 @@ async fn device_authorization_rejects_resource_without_catalog() -> TestResult {
   }
   }
   Ok(())
   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,
   pub refresh_token: &'a str,
   /// Raw `scope` parameter, or `None` when omitted (keeps the grant scope).
   /// Raw `scope` parameter, or `None` when omitted (keeps the grant scope).
   pub requested_scope: Option<&'a str>,
   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`
   /// sorted deduped order; empty when the request carried no `resource`
   /// (RFC 8707 §2.2: the grant keeps its full original audience, NOT §3's
   /// (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],
   pub requested_resources: &'a [String],
   /// Freshly verified proofs for this request (never a stored binding).
   /// Freshly verified proofs for this request (never a stored binding).
   pub proof: &'a SenderProof,
   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`
   /// today's behavior; `Jwt` returns a signed RFC 9068 `at+jwt` whose `aud`
   /// claim is the narrowed access set. The refresh half stays opaque.
   /// claim is the narrowed access set. The refresh half stays opaque.
   pub token_format: TokenFormat,
   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.
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
   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
 /// 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
 /// entitlement or a removed client scope drops out on refresh instead of
 /// surviving for the token's whole lifetime while an explicit narrower
 /// 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
 /// tombstone-cascade, and proof-check behavior. A datastore failure propagates
 /// as an error.
 /// as an error.
 ///
 ///
@@ -155,6 +162,18 @@ pub async fn refresh_with_subject_check(
   let Some(found) = found else {
   let Some(found) = found else {
     return refresh(conn, store, service, input).await;
     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()
   if !found.record.subject.trim().is_empty()
     && matches!(
     && matches!(
       user_repo::subject_account_status(users.pool, users.keys, &found.record.subject).await?,
       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,
     client_registered: input.client_registered,
     user_entitled: &entitled,
     user_entitled: &entitled,
     token_format: input.token_format,
     token_format: input.token_format,
+    resource_indicators_enabled: input.resource_indicators_enabled,
     now_unix: input.now_unix,
     now_unix: input.now_unix,
   };
   };
   refresh(conn, store, service, &fresh).await
   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 reuse;
 mod rotation;
 mod rotation;
 mod scope;
 mod scope;
+mod stale_audience;
 
 
 use std::{
 use std::{
   sync::atomic::{AtomicU64, Ordering},
   sync::atomic::{AtomicU64, Ordering},
@@ -213,6 +214,7 @@ fn input<'a>(
     refresh_token: token,
     refresh_token: token,
     requested_scope: scope,
     requested_scope: scope,
     requested_resources: &[],
     requested_resources: &[],
+    resource_indicators_enabled: false,
     proof,
     proof,
     enabled,
     enabled,
     client_registered: 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,
     client_registered: enabled,
     user_entitled: enabled,
     user_entitled: enabled,
     token_format: TokenFormat::Opaque,
     token_format: TokenFormat::Opaque,
+    resource_indicators_enabled: false,
     now_unix: now_unix(),
     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,
     refresh_token: token,
     requested_scope: scope,
     requested_scope: scope,
     requested_resources: &[],
     requested_resources: &[],
+    resource_indicators_enabled: false,
     proof,
     proof,
     enabled,
     enabled,
     client_registered: registered,
     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,
       refresh_token: &minted.refresh_token,
       requested_scope: Some("read"),
       requested_scope: Some("read"),
       requested_resources: &[],
       requested_resources: &[],
+      resource_indicators_enabled: false,
       proof: &proof,
       proof: &proof,
       enabled: &enabled,
       enabled: &enabled,
       client_registered: &registered,
       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}"),
     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}"),
+  )
+}