Skip to main content

authplane_sdk/
resource.rs

1#![allow(clippy::result_large_err)]
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use jsonwebtoken::decode;
8use jsonwebtoken::decode_header;
9use jsonwebtoken::errors::ErrorKind as JwtErrorKind;
10use jsonwebtoken::jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse};
11use jsonwebtoken::{Algorithm, DecodingKey, Validation};
12use reqwest::Client;
13use serde_json::Value;
14
15use crate::cache::DocumentFetcher;
16use crate::circuit_breaker::CircuitBreaker;
17use crate::client::AuthplaneClient;
18use crate::constants::{jwt_claims, oauth_params};
19use crate::metadata::AuthorizationServerMetadata;
20use crate::oauth::introspect_token;
21use crate::prm::ProtectedResourceMetadata;
22use crate::transport::{build_basic_auth_header, is_http_success, ssrf_safe_get};
23use crate::{AuthplaneError, FetchSettings, build_prm, build_prm_url};
24use crate::{DpopRequestContext, DpopVerificationOptions};
25use crate::{VerifiedClaims, VerifierError};
26
27const DEFAULT_ALLOWED_ALGORITHMS: &[Algorithm] = &[Algorithm::RS256, Algorithm::ES256];
28const JWKS_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(30);
29
30/// Credentials for the RFC 7662 introspection round-trip that backs
31/// revocation checking.
32///
33/// They must belong to a **confidential** client that is either the
34/// issuing client or a runtime-client of the Resource named in the token's
35/// `aud`. Since authserver 0.1.2 any other caller — including a public
36/// (secret-less) client — gets `{"active": false}` for every token, and the
37/// verifier would reject all traffic as revoked. Empty `client_id` or
38/// `client_secret` is therefore refused when the resource is constructed.
39/// Link the resource server's client with
40/// `authserver admin resource runtime-client add --client-id <rs-client-id> --slug <resource-slug>`.
41#[derive(Debug, Clone)]
42pub struct RevocationConfig {
43    pub client_id: String,
44    pub client_secret: String,
45    pub fail_open: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct ResourceOptions {
50    /// Private so the type-system enforces the validation in
51    /// [`Self::with_allowed_algorithms`]: only `RS256` and `ES256` may
52    /// be installed. HMAC (`HS256`/`HS384`/`HS512`), `none`, and every
53    /// other asymmetric variant the underlying JWT crate exposes
54    /// (`RS384`/`RS512`/`PS*`/`ES384`/`EdDSA`) are rejected at
55    /// construction. Read via [`Self::allowed_algorithms`].
56    pub(crate) allowed_algorithms: Vec<Algorithm>,
57    pub clock_skew_seconds: u64,
58    /// Maximum age for inbound DPoP proofs (seconds). Separate from
59    /// `clock_skew_seconds` because DPoP proof TTL and access-token
60    /// clock skew are independent time domains.
61    pub dpop_proof_max_age_seconds: u64,
62    pub revocation: Option<RevocationConfig>,
63    /// Per-resource inbound DPoP configuration (RFC 9449 §7.1 + RFC 9728 §2).
64    ///
65    /// * `None` (default) — Mode 3: resource has NOT opted into DPoP. The
66    ///   verifier rejects any inbound DPoP signal (`cnf.jkt` on the access
67    ///   token or a DPoP proof header) with
68    ///   [`VerifierError::DpopNotSupported`]; PRM omits the `dpop_*`
69    ///   discovery fields entirely.
70    /// * `Some(InboundDPoPOptions::default())` — Mode 2: bearer-only
71    ///   tokens accepted, DPoP-bound tokens validated end-to-end. PRM
72    ///   advertises DPoP capability with
73    ///   `dpop_bound_access_tokens_required: false`.
74    /// * `Some(InboundDPoPOptions::required())` — Mode 1: bearer-only
75    ///   tokens rejected with `VerifierError::DpopBindingMismatch`. PRM
76    ///   advertises `dpop_bound_access_tokens_required: true`.
77    pub inbound_dpop: Option<crate::InboundDPoPOptions>,
78    /// Override for the URL emitted as the RFC 9728 §5.1 `resource_metadata`
79    /// parameter of every `WWW-Authenticate` challenge. `None` (default)
80    /// derives it from the resource identifier per RFC 9728 §3.1
81    /// ([`AuthplaneResource::prm_document_url`]). Set it when the document
82    /// is served elsewhere — e.g. the AS-hosted
83    /// `/.well-known/oauth-protected-resource/{ref}`. Private so the
84    /// absolute-URL check in [`Self::with_resource_metadata_url`] cannot be
85    /// bypassed; read via [`Self::resource_metadata_url`].
86    pub(crate) resource_metadata_url: Option<String>,
87}
88
89impl Default for ResourceOptions {
90    fn default() -> Self {
91        Self {
92            allowed_algorithms: DEFAULT_ALLOWED_ALGORITHMS.to_vec(),
93            clock_skew_seconds: 30,
94            dpop_proof_max_age_seconds: 300,
95            revocation: None,
96            inbound_dpop: None,
97            resource_metadata_url: None,
98        }
99    }
100}
101
102impl ResourceOptions {
103    /// Builder shortcut for opting the resource into inbound DPoP. Equivalent
104    /// to assigning `Some(opts)` to [`Self::inbound_dpop`] via struct-update
105    /// syntax — exists to avoid the four-line boilerplate at call sites:
106    ///
107    /// ```ignore
108    /// ResourceOptions {
109    ///     inbound_dpop: Some(InboundDPoPOptions::default()),
110    ///     ..ResourceOptions::default()
111    /// }
112    /// ```
113    ///
114    /// becomes:
115    ///
116    /// ```ignore
117    /// ResourceOptions::default().with_inbound_dpop(InboundDPoPOptions::default())
118    /// ```
119    pub fn with_inbound_dpop(mut self, opts: crate::InboundDPoPOptions) -> Self {
120        self.inbound_dpop = Some(opts);
121        self
122    }
123
124    /// Restrict the accepted access-token algorithms to a non-empty
125    /// subset of [`DEFAULT_ALLOWED_ALGORITHMS`] (currently `RS256` and
126    /// `ES256`). Returns an error on an empty list or on any algorithm
127    /// outside that allowlist, at construction rather than at the first
128    /// verification.
129    ///
130    /// An allowlist (rather than an HMAC blocklist) is required: the
131    /// jsonwebtoken crate also exposes `RS384`, `RS512`, `PS*`, `ES384`,
132    /// and `EdDSA`. None of these are part of the supported
133    /// access-token algorithm contract; silently accepting them here
134    /// would let a caller advertise an alg in their PRM / JWKS that
135    /// peers can't validate, and broaden the algorithm-confusion
136    /// surface beyond that contract.
137    ///
138    /// Acts as the public construction path; the field is `pub(crate)`
139    /// so the only way to install a custom set from outside the crate
140    /// is through this validator.
141    pub fn with_allowed_algorithms(
142        mut self,
143        algorithms: Vec<Algorithm>,
144    ) -> Result<Self, ResourceOptionsError> {
145        if algorithms.is_empty() {
146            return Err(ResourceOptionsError::EmptyAlgorithmList);
147        }
148        for alg in &algorithms {
149            if !is_supported_access_token_algorithm(*alg) {
150                return Err(ResourceOptionsError::UnsupportedAlgorithm(*alg));
151            }
152        }
153        self.allowed_algorithms = algorithms;
154        Ok(self)
155    }
156
157    /// Borrow the configured access-token algorithm allow-list.
158    pub fn allowed_algorithms(&self) -> &[Algorithm] {
159        &self.allowed_algorithms
160    }
161
162    /// Publish a custom Protected Resource Metadata URL in the
163    /// `resource_metadata` challenge parameter (RFC 9728 §5.1) instead of
164    /// the one derived from the resource identifier.
165    ///
166    /// Rejects anything that is not an absolute URL with a host, at
167    /// construction rather than on the first `401`: a client cannot fetch
168    /// a relative reference out of a header, and RFC 9728 §3.3 gives it no
169    /// recovery path when the document does not resolve.
170    ///
171    /// Whitespace, control characters, `"` and `\` are rejected on the raw
172    /// string, for the reason [`crate::prm`] spells out on the resource
173    /// identifier: the WHATWG parser trims leading and trailing C0/space and
174    /// removes tab and newline anywhere before parsing, so a value carrying
175    /// them parses cleanly while being stored and advertised intact. A
176    /// trailing newline — the shape a file-sourced env var or `$(cat …)`
177    /// produces — would then make `HeaderValue::from_str` fail and drop
178    /// `WWW-Authenticate` from every `401`, which is the header this setter
179    /// exists to populate.
180    pub fn with_resource_metadata_url(
181        mut self,
182        url: impl Into<String>,
183    ) -> Result<Self, ResourceOptionsError> {
184        let url = url.into();
185        let is_absolute = url::Url::parse(&url).is_ok_and(|parsed| parsed.has_host());
186        if !is_absolute {
187            return Err(ResourceOptionsError::InvalidResourceMetadataUrl(url));
188        }
189        if url
190            .chars()
191            .any(|c| c.is_ascii_whitespace() || c.is_ascii_control() || c == '"' || c == '\\')
192        {
193            return Err(ResourceOptionsError::InvalidResourceMetadataUrl(url));
194        }
195        self.resource_metadata_url = Some(url);
196        Ok(self)
197    }
198
199    /// The configured `resource_metadata` override, if any.
200    pub fn resource_metadata_url(&self) -> Option<&str> {
201        self.resource_metadata_url.as_deref()
202    }
203}
204
205/// Configuration error surfaced by the validating `ResourceOptions`
206/// setters.
207#[derive(Debug, thiserror::Error)]
208#[non_exhaustive]
209pub enum ResourceOptionsError {
210    #[error("allowed_algorithms must be non-empty; omit the setter to accept the default set")]
211    EmptyAlgorithmList,
212
213    #[error(
214        "resource_metadata_url must be an absolute URL with a host and must not contain whitespace, control characters, '\"' or '\\', got {0:?}; omit the setter to derive it from the resource identifier (RFC 9728 section 3.1)"
215    )]
216    InvalidResourceMetadataUrl(String),
217
218    #[error(
219        "access-token algorithm {0:?} is not allowed; only `RS256` and `ES256` are accepted. `none`, HMAC (`HS256`/`HS384`/`HS512`), and the additional asymmetric variants the underlying JWT crate exposes (`RS384`/`RS512`/`PS*`/`ES384`/`EdDSA`) are rejected at construction."
220    )]
221    UnsupportedAlgorithm(Algorithm),
222}
223
224#[derive(Debug, Clone)]
225pub struct AuthplaneResource {
226    issuer: String,
227    resource: String,
228    /// URL emitted as the RFC 9728 §5.1 `resource_metadata` challenge
229    /// parameter. Resolved once at construction — the operator's override
230    /// or the RFC 9728 §3.1 derivation from `resource` — so the 401 path
231    /// never re-derives it.
232    resource_metadata_url: String,
233    scopes: Vec<String>,
234    metadata: AuthorizationServerMetadata,
235    fetch_settings: FetchSettings,
236    options: ResourceOptions,
237    http: Client,
238    jwks_state: Arc<std::sync::Mutex<JwksState>>,
239    /// Optional shared JWKS cache (provided by `AuthplaneClient`).
240    /// When `Some`, key lookups bypass the in-process `JwksState` and go
241    /// through the shared cache, picking up background refresh + stale
242    /// fallback + coordinated fetch semantics.
243    jwks_cache: Option<Arc<crate::cache::JwksCache>>,
244    /// Optional live AS-metadata binding (provided by `AuthplaneClient`).
245    /// RFC 8414 §2 publishes `jwks_uri` in the metadata document, so the
246    /// URI the shared `JwksCache` fetches from has to be re-read on the
247    /// configured metadata refresh interval; this binding is what does
248    /// it, driven by the verification traffic below. `None` for the
249    /// `from_prefetched_metadata` / test constructors, whose caller owns
250    /// discovery.
251    metadata_binding: Option<Arc<crate::metadata_binding::MetadataBinding>>,
252    /// Optional shared circuit breaker (provided by `AuthplaneClient`).
253    /// When `Some`, the introspection round-trip on the verify hot path
254    /// participates in the same breaker that gates `AuthplaneClient::introspect`
255    /// — an AS-introspect outage trips the breaker after the threshold
256    /// instead of paying a round-trip per `verify_with_context` call.
257    /// `None` keeps the legacy (un-guarded) path for the
258    /// `from_prefetched_metadata` / test constructors.
259    circuit_breaker: Option<Arc<CircuitBreaker>>,
260}
261
262#[derive(Debug, Clone)]
263struct JwksState {
264    keys: Vec<Jwk>,
265    loaded_at: Instant,
266}
267
268impl AuthplaneResource {
269    pub async fn create(
270        issuer: &str,
271        resource: &str,
272        scopes: &[String],
273        fetch_settings: FetchSettings,
274    ) -> Result<Self, VerifierError> {
275        Self::create_with_options(
276            issuer,
277            resource,
278            scopes,
279            fetch_settings,
280            ResourceOptions::default(),
281        )
282        .await
283    }
284
285    pub async fn create_with_options(
286        issuer: &str,
287        resource: &str,
288        scopes: &[String],
289        fetch_settings: FetchSettings,
290        options: ResourceOptions,
291    ) -> Result<Self, VerifierError> {
292        // Redundant with the `from_parts` gate for the guarantee, but it
293        // reports a malformed identifier before AS discovery: a typo'd
294        // resource costs no network round trip, and against an
295        // unreachable AS the operator is told the identifier is
296        // malformed rather than that the AS is down.
297        validate_resource_for_construction(resource)?;
298        let client = AuthplaneClient::create(issuer, fetch_settings.clone())
299            .await
300            .map_err(|error| VerifierError::MetadataUnavailable {
301                message: error.to_string(),
302            })?;
303        client
304            .resource_with_options(resource, scopes, options)
305            .await
306    }
307
308    #[allow(clippy::too_many_arguments)]
309    pub(crate) async fn from_parts(
310        issuer: String,
311        resource: String,
312        scopes: Vec<String>,
313        metadata: AuthorizationServerMetadata,
314        fetch_settings: FetchSettings,
315        options: ResourceOptions,
316        http: Client,
317        jwks_cache: Option<Arc<crate::cache::JwksCache>>,
318        metadata_binding: Option<Arc<crate::metadata_binding::MetadataBinding>>,
319        circuit_breaker: Option<Arc<CircuitBreaker>>,
320    ) -> Result<Self, VerifierError> {
321        validate_resource_for_construction(&resource)?;
322        validate_revocation_for_construction(&options)?;
323        let resource_metadata_url = resolve_resource_metadata_url(&resource, &options)?;
324        let initial_keys: Vec<Jwk> = if jwks_cache.is_some() {
325            // Shared cache will own JWKS fetching; we only keep an empty
326            // in-memory state for backwards-compat path read-throughs.
327            Vec::new()
328        } else {
329            fetch_jwks(&metadata.jwks_uri, &fetch_settings)
330                .await
331                .map_err(|message| VerifierError::JwksUnavailable { message })?
332        };
333
334        Ok(Self {
335            issuer,
336            resource,
337            resource_metadata_url,
338            scopes,
339            metadata,
340            fetch_settings,
341            options,
342            http,
343            jwks_state: Arc::new(std::sync::Mutex::new(JwksState {
344                keys: initial_keys,
345                loaded_at: Instant::now(),
346            })),
347            jwks_cache,
348            metadata_binding,
349            circuit_breaker,
350        })
351    }
352
353    /// Construct an `AuthplaneResource` from pre-fetched metadata and
354    /// JWKS, bypassing the discovery calls that
355    /// [`AuthplaneResource::create`] performs.
356    ///
357    /// Primarily intended for conformance test harnesses and callers
358    /// that manage JWKS caching externally (e.g. shared in-process
359    /// cache, preloaded fixture). Normal applications should use
360    /// [`AuthplaneResource::create`] or [`AuthplaneResource::create_with_options`]
361    /// so discovery and JWKS fetching happen through the standard
362    /// hardening path.
363    #[doc(hidden)]
364    pub fn from_prefetched_metadata(
365        issuer: &str,
366        resource: &str,
367        scopes: &[String],
368        metadata: AuthorizationServerMetadata,
369        fetch_settings: FetchSettings,
370        options: ResourceOptions,
371        jwks: JwkSet,
372    ) -> Result<Self, VerifierError> {
373        validate_resource_for_construction(resource)?;
374        validate_revocation_for_construction(&options)?;
375        let resource_metadata_url = resolve_resource_metadata_url(resource, &options)?;
376        let http = crate::transport::build_http_client(&fetch_settings).map_err(|error| {
377            VerifierError::MetadataUnavailable {
378                message: error.to_string(),
379            }
380        })?;
381        Ok(Self {
382            issuer: issuer.to_string(),
383            resource: resource.to_string(),
384            resource_metadata_url,
385            scopes: scopes.to_vec(),
386            metadata,
387            fetch_settings,
388            options,
389            http,
390            jwks_state: Arc::new(std::sync::Mutex::new(JwksState {
391                keys: jwks.keys,
392                loaded_at: Instant::now(),
393            })),
394            jwks_cache: None,
395            metadata_binding: None,
396            circuit_breaker: None,
397        })
398    }
399
400    #[cfg(test)]
401    pub(crate) fn from_metadata_and_jwks(
402        issuer: &str,
403        resource: &str,
404        scopes: &[String],
405        metadata: AuthorizationServerMetadata,
406        fetch_settings: FetchSettings,
407        options: ResourceOptions,
408        jwks: JwkSet,
409    ) -> Self {
410        Self::from_prefetched_metadata(
411            issuer,
412            resource,
413            scopes,
414            metadata,
415            fetch_settings,
416            options,
417            jwks,
418        )
419        .expect("valid fetch settings")
420    }
421
422    /// Test-only constructor variant that wires a `CircuitBreaker` into the
423    /// resource so tests can drive the short-circuit branches of
424    /// [`Self::verify`]'s revocation path (open + fail-open accepts the
425    /// token; open + fail-closed surfaces `MetadataUnavailable`). Mirrors
426    /// the production path where `AuthplaneClient::resource_with_options`
427    /// passes its own `Arc<CircuitBreaker>`.
428    #[cfg(test)]
429    #[allow(clippy::too_many_arguments)]
430    pub(crate) fn from_metadata_jwks_and_breaker(
431        issuer: &str,
432        resource: &str,
433        scopes: &[String],
434        metadata: AuthorizationServerMetadata,
435        fetch_settings: FetchSettings,
436        options: ResourceOptions,
437        jwks: JwkSet,
438        circuit_breaker: Arc<CircuitBreaker>,
439    ) -> Self {
440        let http =
441            crate::transport::build_http_client(&fetch_settings).expect("valid fetch settings");
442        let resource_metadata_url =
443            resolve_resource_metadata_url(resource, &options).expect("valid resource");
444        Self {
445            issuer: issuer.to_string(),
446            resource: resource.to_string(),
447            resource_metadata_url,
448            scopes: scopes.to_vec(),
449            metadata,
450            fetch_settings,
451            options,
452            http,
453            jwks_state: Arc::new(std::sync::Mutex::new(JwksState {
454                keys: jwks.keys,
455                loaded_at: Instant::now(),
456            })),
457            jwks_cache: None,
458            metadata_binding: None,
459            circuit_breaker: Some(circuit_breaker),
460        }
461    }
462
463    pub fn resource(&self) -> &str {
464        &self.resource
465    }
466
467    pub fn issuer(&self) -> &str {
468        &self.issuer
469    }
470
471    pub fn scopes(&self) -> &[String] {
472        &self.scopes
473    }
474
475    pub fn prm_response(&self) -> ProtectedResourceMetadata {
476        // RFC 9728 §2 + RFC 9449 §7.1 — resources advertise DPoP support
477        // here so OAuth-discovery clients can mint matching tokens. When
478        // `inbound_dpop` is not configured (Mode 3), the document omits
479        // `dpop_*` entirely; when configured, it lists the accepted proof
480        // algorithms and sets `dpop_bound_access_tokens_required` from
481        // the `required` flag (Mode 1 vs Mode 2).
482        let (dpop_algs, dpop_required) = match self.options.inbound_dpop.as_ref() {
483            Some(inbound) => {
484                // `filter_map` drops any algorithm `prm_alg_label` can't name
485                // (today only the HMAC variants, which the upstream allowlist
486                // filters already reject — see `prm_alg_label`'s doc). Failing
487                // closed by omission keeps the PRM document well-formed and
488                // serves a degraded discovery answer rather than panicking on
489                // a per-request hot path; a `debug_assert!` inside
490                // `prm_alg_label` still surfaces the regression in
491                // dev / test builds.
492                let algs: Vec<String> = inbound
493                    .resolved_allowed_proof_algorithms()
494                    .iter()
495                    .filter_map(prm_alg_label)
496                    .collect();
497                (Some(algs), inbound.is_required())
498            }
499            None => (None, false),
500        };
501        build_prm(
502            &self.issuer,
503            &self.resource,
504            &self.scopes,
505            dpop_algs.as_deref(),
506            dpop_required,
507        )
508    }
509
510    /// RFC 9728 §3.1 — absolute URL of this resource's metadata document,
511    /// derived from the resource identifier. This is where a server that
512    /// hosts its own document should serve it.
513    pub fn prm_document_url(&self) -> Result<String, AuthplaneError> {
514        build_prm_url(&self.resource)
515    }
516
517    /// The URL advertised as the RFC 9728 §5.1 `resource_metadata`
518    /// parameter of every `WWW-Authenticate` challenge this resource
519    /// emits: the [`ResourceOptions::with_resource_metadata_url`] override
520    /// when set, otherwise [`Self::prm_document_url`].
521    pub fn resource_metadata_url(&self) -> &str {
522        &self.resource_metadata_url
523    }
524
525    /// Build the `WWW-Authenticate` challenge for a verifier failure on
526    /// this resource: [`www_authenticate()`](crate::www_authenticate()) plus the RFC 9728 §5.1
527    /// `resource_metadata` parameter carrying
528    /// [`Self::resource_metadata_url`]. Adapters should prefer this over
529    /// the free function so every `401` tells the client where to discover
530    /// the authorization server. `realm` is optional; pass `""` to omit it.
531    pub fn www_authenticate(&self, error: &VerifierError, realm: &str) -> String {
532        crate::www_authenticate::www_authenticate_with_resource_metadata(
533            error,
534            realm,
535            &self.resource_metadata_url,
536        )
537    }
538
539    /// Signature and claims only — no DPoP mode dispatch.
540    ///
541    /// The shared core of [`verify`](Self::verify) and
542    /// [`verify_with_context`](Self::verify_with_context). Deliberately
543    /// unaware of `cnf`: the binding guard belongs to the bearer-only
544    /// entrypoint and the three-mode dispatch to the context one, and putting
545    /// either here would break the other. `verify` used to *be* this function,
546    /// which is how a DPoP-bound token could be accepted as a plain bearer
547    /// token by anything calling it.
548    async fn verify_claims_only(&self, token: &str) -> Result<VerifiedClaims, VerifierError> {
549        if token.trim().is_empty() {
550            return Err(VerifierError::TokenMissing);
551        }
552
553        let header = decode_header(token).map_err(|error| VerifierError::InvalidSignature {
554            message: error.to_string(),
555        })?;
556
557        let kid = header.kid.ok_or_else(|| VerifierError::InvalidClaims {
558            message: "token header missing 'kid' field".to_string(),
559        })?;
560        let alg = header.alg;
561        let typ = header.typ.ok_or_else(|| VerifierError::InvalidClaims {
562            message: "token header missing 'typ' field".to_string(),
563        })?;
564
565        // RFC 9068 §2.1: access tokens MUST use typ "at+jwt". We enforce this
566        // strictly — tokens with "JWT" or missing typ are rejected, which
567        // prevents type-confusion attacks where a generic JWT is accepted as
568        // an access token.
569        if typ != "at+jwt" {
570            return Err(VerifierError::InvalidClaims {
571                message: format!("token type must be 'at+jwt', got {typ:?}"),
572            });
573        }
574        if !self.options.allowed_algorithms.contains(&alg) || is_dangerous_algorithm(alg) {
575            return Err(VerifierError::InvalidClaims {
576                message: format!("token algorithm {alg:?} is not allowed"),
577            });
578        }
579
580        let key = self.lookup_key(&kid, alg).await?;
581
582        let decoding_key =
583            DecodingKey::from_jwk(&key).map_err(|error| VerifierError::JwksUnavailable {
584                message: error.to_string(),
585            })?;
586
587        let mut validation = Validation::new(alg);
588        validation.leeway = self.options.clock_skew_seconds;
589        validation.validate_nbf = true;
590        validation.set_required_spec_claims(&[jwt_claims::EXP, jwt_claims::ISS, jwt_claims::AUD]);
591        validation.set_issuer(&[self.issuer.as_str()]);
592        validation.set_audience(&[self.resource.as_str()]);
593
594        let verified = decode::<Value>(token, &decoding_key, &validation)
595            .map_err(|error| map_jwt_error(error.kind(), &kid))?;
596        let payload = verified.claims;
597
598        let claims = build_verified_claims(&payload, &kid, self.options.clock_skew_seconds)?;
599
600        if let Some(revocation) = &self.options.revocation {
601            // CRITICAL: gate the introspection round-trip through the shared
602            // CircuitBreaker when one is wired. `AuthplaneClient::introspect`
603            // already does this via `run_guarded`, but the resource path —
604            // which validates every inbound token in production — used to
605            // call introspect_token directly. An AS-introspect outage paid
606            // a full round-trip per verify, and the breaker never opened
607            // because failures were not recorded against it. With the
608            // breaker plumbed in, an outage trips the breaker after the
609            // configured threshold and subsequent verify() calls
610            // short-circuit on AuthplaneError::CircuitOpen instead.
611            if let Some(breaker) = &self.circuit_breaker
612                && !breaker.allow()
613            {
614                if revocation.fail_open {
615                    // Circuit open + fail-open: accept the token without
616                    // round-tripping. Same lenient policy the legacy
617                    // path applied to network errors.
618                    return Ok(claims);
619                }
620                return Err(VerifierError::MetadataUnavailable {
621                    message: "authorization server circuit breaker is open".to_string(),
622                });
623            }
624
625            let auth_header =
626                build_basic_auth_header(&revocation.client_id, &revocation.client_secret);
627            let introspection_result = introspect_token(
628                &self.http,
629                self.metadata.introspection_endpoint().map_err(|error| {
630                    VerifierError::MetadataUnavailable {
631                        message: error.to_string(),
632                    }
633                })?,
634                token,
635                &auth_header,
636                &self.fetch_settings,
637                None,
638            )
639            .await;
640
641            match introspection_result {
642                Ok(response) => {
643                    if let Some(breaker) = &self.circuit_breaker {
644                        breaker.record_success();
645                    }
646                    if !response.active {
647                        return Err(VerifierError::TokenRevoked);
648                    }
649                }
650                Err(error) => {
651                    // Only count this against the breaker if it would also
652                    // count on the `AuthplaneClient` introspection path —
653                    // otherwise a benign OAuth response from the AS (e.g.
654                    // `invalid_client` from rotated credentials) would trip
655                    // the breaker here while leaving the client path
656                    // healthy, then degrade every inbound token check for
657                    // the cooldown window (fail_open=true silently accepts
658                    // possibly-revoked tokens; fail_open=false rejects all
659                    // traffic). The shared predicate lives in
660                    // `circuit_policy` so the two consumers cannot drift.
661                    if let Some(breaker) = &self.circuit_breaker
662                        && crate::circuit_policy::should_count_failure(&error)
663                    {
664                        breaker.record_failure();
665                    }
666                    if !revocation.fail_open {
667                        return Err(VerifierError::MetadataUnavailable {
668                            message: error.to_string(),
669                        });
670                    }
671                }
672            }
673        }
674
675        Ok(claims)
676    }
677
678    /// Verify a **bearer** access token (RFC 6750 §2.1).
679    ///
680    /// Rejects a DPoP-bound token rather than accepting it as a bearer one.
681    /// A `cnf` claim means the authorization server issued this token
682    /// sender-constrained, and the binding is the whole reason a stolen token
683    /// is useless to a thief. This entrypoint has no request context, so it
684    /// has no proof to check against — accepting the token anyway would
685    /// silently discard the constraint, which is a downgrade, not a
686    /// limitation.
687    ///
688    /// * Token **without** `cnf`: verified and returned.
689    /// * Token **with** `cnf`, resource **not** configured for inbound DPoP
690    ///   (Mode 3): [`VerifierError::DpopNotSupported`] — the same answer
691    ///   [`verify_with_context`](Self::verify_with_context) gives.
692    /// * Token **with** `cnf`, resource **is** configured for inbound DPoP
693    ///   (Modes 1 and 2): [`VerifierError::DpopBindingMismatch`] — a proof is
694    ///   required and this entrypoint cannot supply one. Use
695    ///   [`verify_with_context`](Self::verify_with_context).
696    ///
697    /// Mode dispatch comes first, so a malformed `cnf` cannot select a weaker
698    /// path than a well-formed one. It is *not* separated out beyond that:
699    /// `verify_with_context` checks for `cnf.jkt` before the proof-missing
700    /// check and answers [`VerifierError::InvalidClaims`] for a `cnf` without
701    /// one, which this entrypoint does not reproduce — there is no proof to
702    /// bind either way here, so the malformed case folds into the same
703    /// rejection. Both reject; the class differs, and the catalog case that
704    /// pins `invalid_claims` for that shape
705    /// (`rfc9449-dpop-bound-token-must-contain-cnf-jkt`) is specified against
706    /// the DPoP entrypoint with a proof present, not against this one.
707    pub async fn verify(&self, token: &str) -> Result<VerifiedClaims, VerifierError> {
708        let claims = self.verify_claims_only(token).await?;
709
710        // RFC 7800 §3.1 — a `cnf` of any object shape makes the token bound.
711        if claims
712            .raw
713            .get(jwt_claims::CNF)
714            .and_then(Value::as_object)
715            .is_some()
716        {
717            return Err(if self.options.inbound_dpop.is_none() {
718                VerifierError::DpopNotSupported
719            } else {
720                // Not `DpopProofMissing`: that variant means a request context
721                // was supplied and carried no proof. This entrypoint takes no
722                // context at all, which is a different thing to tell a caller
723                // matching on the error.
724                VerifierError::DpopBindingMismatch {
725                    message: "access token is DPoP-bound (`cnf.jkt` present) but no DPoP \
726                              request context was provided; use verify_with_context"
727                        .to_string(),
728                }
729            });
730        }
731
732        Ok(claims)
733    }
734
735    /// RFC 9449 §7 — unified verify entrypoint.
736    ///
737    /// Accepts both bearer and DPoP-bound access tokens behind a single
738    /// API that takes a request-level [`DpopRequestContext`] and uses
739    /// the token's `cnf.jkt` claim to decide whether sender-constraint
740    /// validation must run, so a caller need not decide up front which
741    /// entrypoint a token requires.
742    ///
743    /// Behavior matrix:
744    ///
745    /// * Token **without** `cnf`/`cnf.jkt`: ignored request context;
746    ///   verification succeeds as a bearer token.
747    /// * Token **with** `cnf` but no `cnf.jkt`: rejected with
748    ///   [`VerifierError::InvalidClaims`] — structurally malformed
749    ///   confirmation claim.
750    /// * Token **with** `cnf.jkt` and `context.proof == None`: rejected
751    ///   with [`VerifierError::DpopProofMissing`].
752    /// * Token **with** `cnf.jkt` and `context.proof = Some(_)`: proof
753    ///   is validated (method, URL, nonce, `ath`, and `cnf.jkt` ↔ `jwk`
754    ///   thumbprint binding). Mismatch surfaces as
755    ///   [`VerifierError::InvalidClaims`].
756    pub async fn verify_with_context(
757        &self,
758        token: &str,
759        context: &DpopRequestContext,
760    ) -> Result<VerifiedClaims, VerifierError> {
761        let claims = self.verify_claims_only(token).await?;
762
763        let cnf_object = claims.raw.get(jwt_claims::CNF).and_then(Value::as_object);
764        // RFC 7800 §3.1 — a `cnf` of any object shape makes the token bound
765        // for mode-dispatch purposes. The jkt-presence check happens AFTER
766        // mode dispatch so a malformed cnf-without-jkt surfaces as
767        // InvalidClaims regardless of Mode 1/2.
768        let token_is_bound = cnf_object.is_some();
769        // Constructors normalize blank proofs to `None`, so presence is
770        // exactly `is_some()` — mode dispatch and proof verification can
771        // never disagree over a `Some("")` proof.
772        let proof_present = context.proof.is_some();
773
774        // RFC 9449 §6 / RFC 9728 §2 — three-mode inbound DPoP.
775        //
776        // Mode 3 (inbound_dpop = None): resource has NOT opted into DPoP.
777        // Any DPoP signal on the request — bound token OR proof header —
778        // is rejected upfront. Silent downgrade to bearer would drop sender-
779        // binding; ad-hoc defaults applied here would be invisible in PRM.
780        let Some(inbound) = self.options.inbound_dpop.as_ref() else {
781            if token_is_bound || proof_present {
782                return Err(VerifierError::DpopNotSupported);
783            }
784            return Ok(claims);
785        };
786
787        // Modes 1 & 2 — resource supports DPoP.
788        if !token_is_bound {
789            // Mode 1 — `required = true` rejects bearer-only tokens.
790            if inbound.is_required() {
791                return Err(VerifierError::DpopBindingMismatch {
792                    message:
793                        "Resource requires DPoP-bound access tokens but the presented token has no `cnf.jkt`"
794                            .to_string(),
795                });
796            }
797
798            // Mode 2 with a stray proof attached to a bearer-only token —
799            // the proof's `ath` has nothing to bind to, so it's malformed.
800            if proof_present {
801                return Err(VerifierError::DpopBindingMismatch {
802                    message:
803                        "DPoP proof presented but the access token is not DPoP-bound (`cnf.jkt` missing); \
804                         the proof has nothing to bind to"
805                            .to_string(),
806                });
807            }
808
809            // Bearer token, Mode 2 — accepted. The request context is
810            // informational; any (absent) proof is ignored.
811            return Ok(claims);
812        }
813
814        // Token is DPoP-bound — `cnf` is present but must carry a non-empty `jkt`.
815        let cnf = cnf_object.expect("token_is_bound implies cnf_object is Some");
816        let expected_jkt = cnf
817            .get(jwt_claims::JKT)
818            .and_then(Value::as_str)
819            .filter(|value| !value.is_empty())
820            .ok_or_else(|| VerifierError::InvalidClaims {
821                message:
822                    "DPoP-bound access token carries 'cnf' but is missing required 'cnf.jkt' claim"
823                        .to_string(),
824            })?
825            .to_string();
826
827        let proof = context
828            .proof
829            .as_deref()
830            .ok_or(VerifierError::DpopProofMissing)?;
831
832        // Resolve verification parameters: per-resource inbound_dpop fields
833        // override the resource-level defaults when set explicitly.
834        let allowed_algs = inbound.resolved_allowed_proof_algorithms();
835        let clock_skew = inbound.resolved_clock_skew_seconds(self.options.clock_skew_seconds);
836        let max_age =
837            inbound.resolved_max_proof_age_seconds(self.options.dpop_proof_max_age_seconds);
838
839        let dpop_options = DpopVerificationOptions {
840            expected_access_token: Some(token),
841            expected_nonce: context.nonce.as_deref(),
842            allowed_algorithms: &allowed_algs,
843            clock_skew_seconds: clock_skew,
844            max_age_seconds: max_age,
845        };
846
847        // Replay-store resolution: configured per-resource via `inbound_dpop`
848        // (RFC 9449 §11.1). `InboundDPoPOptions::default()` auto-allocates
849        // an `InMemoryDpopReplayStore` so this branch is unconditional —
850        // any DPoP-bound request that reaches `verify_with_context` runs
851        // through the with-replay path. Multi-process deployments install
852        // a shared store via `InboundDPoPOptions::with_replay_store`.
853        // Per-request replay-store override was removed deliberately: two
854        // requests racing with different store instances would deduplicate
855        // independently, defeating the JTI single-use guarantee. The
856        // resource-level configuration is the single source of truth.
857        // Use the jkt-first variant: comparing cnf.jkt BEFORE the jti commit
858        // closes the slot-poisoning window. An attacker who knows a
859        // legitimate jti could otherwise submit a proof bearing that jti
860        // with the wrong jkt; the jti gets registered in the replay store
861        // before the (post-verify) jkt check fires, and the legitimate
862        // proof carrying the same jti is then rejected as a replay.
863        let verified = crate::dpop::verify_dpop_proof_with_jkt_and_replay(
864            proof,
865            context.method.as_str(),
866            context.url.as_str(),
867            dpop_options,
868            &expected_jkt,
869            inbound.replay_store().as_ref(),
870        )
871        .await?;
872
873        let mut claims = claims;
874        claims.dpop_proof = Some(verified);
875        Ok(claims)
876    }
877
878    fn find_key(&self, kid: &str, alg: Algorithm) -> Option<Jwk> {
879        let state = self.jwks_state.lock().expect("jwks mutex poisoned");
880        state
881            .keys
882            .iter()
883            .find(|jwk| jwk_matches(jwk, kid, alg))
884            .cloned()
885    }
886
887    /// Resolve a JWK by `kid`/`alg`, preferring the shared [`JwksCache`]
888    /// when one was provided by [`AuthplaneClient`]. Falls back to the
889    /// in-process `JwksState` (with the legacy refresh-on-miss policy)
890    /// for callers that built the resource via `from_prefetched_metadata`.
891    async fn lookup_key(&self, kid: &str, alg: Algorithm) -> Result<Jwk, VerifierError> {
892        // RFC 8414 §2 keeps `jwks_uri` in the metadata document, so key
893        // resolution has to start from a document that is still current.
894        // Following a rotation therefore costs nothing beyond the
895        // verification traffic already flowing: this re-reads metadata
896        // only once the configured refresh interval has elapsed, and
897        // rebinds the shared JWKS cache when the URI changed. Runs before
898        // the lookup so a rotation that landed during the interval is
899        // already bound by the time keys are read.
900        if let Some(binding) = self.metadata_binding.as_ref() {
901            binding.refresh_if_due().await;
902        }
903
904        if let Some(cache) = self.jwks_cache.as_ref() {
905            let alg_label = alg_jose_label(alg);
906            let mut found = cache
907                .get_key_by_kid(kid, alg_label)
908                .await
909                .map_err(|error| VerifierError::JwksUnavailable {
910                    message: error.to_string(),
911                })?;
912
913            // A `kid` the bound key set does not contain is the request that
914            // proves the binding is stale, and the metadata document naming
915            // where keys live is no more current than the key set it
916            // produced. Re-read it here rather than waiting for the interval
917            // gate above, which is a no-op until the interval is up: an AS
918            // that rotates `jwks_uri` and retires the old key set at the same
919            // moment would otherwise fail every verification until then. The
920            // re-read is floored inside the binding, so an arbitrary `kid`
921            // cannot turn the pre-authentication path into one discovery
922            // fetch per request. Retried only when `jwks_uri` actually moved
923            // — the rebind expires the keys cached from the withdrawn URL,
924            // so that is the only case where a second lookup can answer
925            // differently.
926            if found.is_none()
927                && let Some(binding) = self.metadata_binding.as_ref()
928                && binding.refresh_on_kid_miss().await
929            {
930                found = cache
931                    .get_key_by_kid(kid, alg_label)
932                    .await
933                    .map_err(|error| VerifierError::JwksUnavailable {
934                        message: error.to_string(),
935                    })?;
936            }
937
938            let value = found.ok_or_else(|| VerifierError::InvalidSignature {
939                message: format!("token kid {kid:?} not found in JWKS after refresh"),
940            })?;
941            let jwk: Jwk =
942                serde_json::from_value(value).map_err(|error| VerifierError::JwksUnavailable {
943                    message: error.to_string(),
944                })?;
945            return Ok(jwk);
946        }
947
948        // Legacy in-process state path.
949        if let Some(found) = self.find_key(kid, alg) {
950            return Ok(found);
951        }
952        if self.should_refresh_jwks() {
953            self.refresh_jwks().await?;
954        }
955        self.find_key(kid, alg)
956            .ok_or_else(|| VerifierError::InvalidSignature {
957                message: format!("token kid {kid:?} not found in JWKS after refresh"),
958            })
959    }
960
961    async fn refresh_jwks(&self) -> Result<(), VerifierError> {
962        let keys = fetch_jwks(&self.metadata.jwks_uri, &self.fetch_settings)
963            .await
964            .map_err(|message| VerifierError::JwksUnavailable { message })?;
965
966        let mut state = self.jwks_state.lock().expect("jwks mutex poisoned");
967        state.keys = keys;
968        state.loaded_at = Instant::now();
969        Ok(())
970    }
971
972    fn should_refresh_jwks(&self) -> bool {
973        let state = self.jwks_state.lock().expect("jwks mutex poisoned");
974        state.keys.is_empty() || state.loaded_at.elapsed() >= JWKS_REFRESH_MIN_INTERVAL
975    }
976}
977
978/// Construction-time gate on the configured resource identifier: it must
979/// be an absolute URL with a scheme and a host, free of fragment,
980/// whitespace/control characters, and userinfo (RFC 8707 §2 + RFC 9728
981/// §3 — see `prm::validate_resource_identifier` for the full grounding).
982///
983/// Runs in every constructor funnel (`from_parts` behind
984/// `create`/`create_with_options`/`AuthplaneClient::resource*`, plus
985/// `from_prefetched_metadata`) so a malformed identifier fails when the
986/// resource is built — not later, when the 401-challenge path first
987/// tries to derive the PRM document URL from it. The underlying
988/// rejection carries the module's `invalid_resource` error code; it
989/// surfaces here as `MetadataUnavailable`, the same variant these
990/// constructors already use for other construction-time failures, which
991/// deliberately maps to no `WWW-Authenticate` challenge code.
992fn validate_resource_for_construction(resource: &str) -> Result<(), VerifierError> {
993    crate::prm::validate_resource_identifier(resource).map_err(|error| {
994        VerifierError::MetadataUnavailable {
995            message: error.to_string(),
996        }
997    })
998}
999
1000/// Construction-time gate on [`RevocationConfig`]: introspection needs
1001/// confidential client credentials. Since authserver 0.1.2 an
1002/// unauthenticated or public-client introspection call gets
1003/// `{"active": false}` for every token, so a resource built with empty
1004/// credentials would not be "less strict" — it would reject all traffic as
1005/// revoked, and silently. Fail here, where the cause is attributable, with
1006/// the same variant the other construction-time checks use.
1007fn validate_revocation_for_construction(options: &ResourceOptions) -> Result<(), VerifierError> {
1008    let Some(revocation) = &options.revocation else {
1009        return Ok(());
1010    };
1011    if revocation.client_id.trim().is_empty() || revocation.client_secret.trim().is_empty() {
1012        return Err(VerifierError::MetadataUnavailable {
1013            message: "RevocationConfig requires a confidential client: client_id and \
1014                      client_secret must be non-empty. authserver >= 0.1.2 answers \
1015                      active=false to unauthenticated introspection, so every token would \
1016                      be rejected as revoked"
1017                .to_string(),
1018        });
1019    }
1020    Ok(())
1021}
1022
1023/// The URL for the `resource_metadata` challenge parameter: the operator's
1024/// override when set, otherwise the RFC 9728 §3.1 derivation. The
1025/// derivation can only fail on an identifier
1026/// `validate_resource_for_construction` already rejected, so the error arm
1027/// is defensive.
1028fn resolve_resource_metadata_url(
1029    resource: &str,
1030    options: &ResourceOptions,
1031) -> Result<String, VerifierError> {
1032    match options.resource_metadata_url() {
1033        Some(url) => Ok(url.to_string()),
1034        None => build_prm_url(resource).map_err(|error| VerifierError::MetadataUnavailable {
1035            message: error.to_string(),
1036        }),
1037    }
1038}
1039
1040async fn fetch_jwks(jwks_uri: &str, fetch_settings: &FetchSettings) -> Result<Vec<Jwk>, String> {
1041    // CRITICAL: always go through ssrf_safe_get so DNS pinning + IP-allowlist
1042    // checks run between the lexical URL validation and the TCP connect. The
1043    // previous direct http.get() path only ran validate_fetch_url (lexical),
1044    // leaving a DNS-rebinding window where the resolved IP could be swapped
1045    // to a cloud-metadata address between validation and connect. The
1046    // ssrf_safe_get path resolves the host, validates every returned IP
1047    // against the allow-list, and connects to the pinned IP with the
1048    // original Host header preserved.
1049    let response = ssrf_safe_get(
1050        jwks_uri,
1051        fetch_settings,
1052        DocumentFetcher::DEFAULT_JWKS_MAX_BYTES,
1053    )
1054    .await
1055    .map_err(|error| error.to_string())?;
1056
1057    let status = response.status_code;
1058    if !is_http_success(status) {
1059        // `ssrf_safe_get` parses the body as JSON and falls back to
1060        // `Value::Null` on parse failure (see `transport::ssrf_safe_request`),
1061        // so a non-JSON error page would render as the literal string
1062        // `null` here. Surface a clearer hint instead of the misleading
1063        // payload — for the proper fix, transport would have to carry the
1064        // raw bytes alongside the parsed `Value`.
1065        let body_hint = if response.body.is_null() {
1066            "<non-JSON response body>".to_string()
1067        } else {
1068            response.body.to_string()
1069        };
1070        return Err(format!(
1071            "jwks fetch failed with status {status}: {body_hint}"
1072        ));
1073    }
1074
1075    let jwks: JwkSet = serde_json::from_value(response.body).map_err(|error| error.to_string())?;
1076    Ok(jwks.keys)
1077}
1078
1079/// Map a `jsonwebtoken::Algorithm` to its RFC 7518 / IANA JOSE-Algorithms
1080/// short name. Returns `None` for the HMAC variants (`HS*`), which are
1081/// rejected upstream by [`is_dangerous_algorithm`] and by
1082/// [`InboundDPoPOptions::with_allowed_proof_algorithms`] /
1083/// [`ResourceOptions::with_allowed_algorithms`]. Used by both the PRM
1084/// document (`dpop_signing_alg_values_supported`, RFC 9449 §7.1 + RFC
1085/// 9728 §2) and the JWKS cache lookup path that fans out by alg.
1086fn alg_jose_label(alg: Algorithm) -> Option<&'static str> {
1087    match alg {
1088        Algorithm::RS256 => Some("RS256"),
1089        Algorithm::RS384 => Some("RS384"),
1090        Algorithm::RS512 => Some("RS512"),
1091        Algorithm::PS256 => Some("PS256"),
1092        Algorithm::PS384 => Some("PS384"),
1093        Algorithm::PS512 => Some("PS512"),
1094        Algorithm::ES256 => Some("ES256"),
1095        Algorithm::ES384 => Some("ES384"),
1096        Algorithm::EdDSA => Some("EdDSA"),
1097        // HMAC: never reaches a PRM document or a JWKS lookup we'd serve.
1098        Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => None,
1099    }
1100}
1101
1102/// PRM variant of [`alg_jose_label`]. Returns `Some(label)` for any algorithm
1103/// the SDK is willing to advertise in `dpop_signing_alg_values_supported`,
1104/// and `None` for the HMAC variants — those should never reach this function
1105/// (the upstream allowlist filters `is_dangerous_algorithm`,
1106/// `InboundDPoPOptions::with_allowed_proof_algorithms`,
1107/// `ResourceOptions::with_allowed_algorithms`, and the private
1108/// `SUPPORTED_DPOP_ALGORITHMS` constant all reject HMAC), but `None` keeps
1109/// the PRM-emission path total: if a regression ever bypasses every filter,
1110/// the unsupported entry is silently dropped from the advertised list rather
1111/// than panicking in the per-request `prm_response()` hot path.
1112///
1113/// A `debug_assert!` still surfaces the regression in dev / test builds so
1114/// CI flags any future bypass loudly, without taking down a Tokio task in
1115/// production.
1116fn prm_alg_label(alg: &Algorithm) -> Option<String> {
1117    let label = alg_jose_label(*alg).map(str::to_string);
1118    debug_assert!(
1119        label.is_some(),
1120        "prm_alg_label received unsupported algorithm {alg:?}; \
1121         InboundDPoPOptions::with_allowed_proof_algorithms / \
1122         SUPPORTED_DPOP_ALGORITHMS should have rejected it",
1123    );
1124    label
1125}
1126
1127fn jwk_matches(jwk: &Jwk, kid: &str, alg: Algorithm) -> bool {
1128    if jwk.common.key_id.as_deref() != Some(kid) {
1129        return false;
1130    }
1131
1132    if let Some(use_hint) = &jwk.common.public_key_use
1133        && use_hint != &PublicKeyUse::Signature
1134    {
1135        return false;
1136    }
1137
1138    if let Some(key_ops) = &jwk.common.key_operations
1139        && !key_ops
1140            .iter()
1141            .any(|operation| operation == &KeyOperations::Verify)
1142    {
1143        return false;
1144    }
1145
1146    if let Some(key_alg) = jwk.common.key_algorithm
1147        && key_algorithm_to_jwt(key_alg) != Some(alg)
1148    {
1149        return false;
1150    }
1151
1152    true
1153}
1154
1155fn key_algorithm_to_jwt(value: KeyAlgorithm) -> Option<Algorithm> {
1156    match value {
1157        KeyAlgorithm::RS256 => Some(Algorithm::RS256),
1158        KeyAlgorithm::RS384 => Some(Algorithm::RS384),
1159        KeyAlgorithm::RS512 => Some(Algorithm::RS512),
1160        KeyAlgorithm::PS256 => Some(Algorithm::PS256),
1161        KeyAlgorithm::PS384 => Some(Algorithm::PS384),
1162        KeyAlgorithm::PS512 => Some(Algorithm::PS512),
1163        KeyAlgorithm::ES256 => Some(Algorithm::ES256),
1164        KeyAlgorithm::ES384 => Some(Algorithm::ES384),
1165        _ => None,
1166    }
1167}
1168
1169fn is_dangerous_algorithm(alg: Algorithm) -> bool {
1170    matches!(alg, Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512)
1171}
1172
1173/// Only `RS256` and `ES256` are part of the access-token contract.
1174/// Used by
1175/// [`ResourceOptions::with_allowed_algorithms`] at construction so we
1176/// reject (not just HMAC, but) every other variant the underlying
1177/// `jsonwebtoken` crate exposes.
1178fn is_supported_access_token_algorithm(alg: Algorithm) -> bool {
1179    matches!(alg, Algorithm::RS256 | Algorithm::ES256)
1180}
1181
1182fn build_verified_claims(
1183    payload: &Value,
1184    kid: &str,
1185    clock_skew_seconds: u64,
1186) -> Result<VerifiedClaims, VerifierError> {
1187    let object = payload
1188        .as_object()
1189        .ok_or_else(|| VerifierError::InvalidClaims {
1190            message: "token payload must be a JSON object".to_string(),
1191        })?;
1192
1193    let issuer = required_string(payload, jwt_claims::ISS)?;
1194    let subject = required_string(payload, jwt_claims::SUB)?;
1195    let client_id = required_string(payload, jwt_claims::CLIENT_ID)?;
1196    let jti = required_string(payload, jwt_claims::JTI)?;
1197    let expires_at = required_i64(payload, jwt_claims::EXP)?;
1198    let issued_at = required_i64(payload, jwt_claims::IAT)?;
1199    let now = unix_now();
1200    if issued_at > now + clock_skew_seconds as i64 {
1201        return Err(VerifierError::InvalidClaims {
1202            message: format!(
1203                "token 'iat' claim is in the future (iat={issued_at}, now={now}, leeway={}s)",
1204                clock_skew_seconds
1205            ),
1206        });
1207    }
1208
1209    let audience = parse_audience(payload.get(jwt_claims::AUD)).ok_or_else(|| {
1210        VerifierError::InvalidClaims {
1211            message: "token missing required 'aud' claim".to_string(),
1212        }
1213    })?;
1214
1215    let not_before = payload
1216        .get(jwt_claims::NBF)
1217        .and_then(Value::as_i64)
1218        .unwrap_or_default();
1219    let scopes = payload
1220        .get(oauth_params::SCOPE)
1221        .and_then(Value::as_str)
1222        .map(|value| {
1223            value
1224                .split_whitespace()
1225                .filter(|scope| !scope.is_empty())
1226                .map(ToString::to_string)
1227                .collect::<Vec<_>>()
1228        })
1229        .unwrap_or_default();
1230
1231    let agent_id = payload
1232        .get(jwt_claims::AGENT_ID)
1233        .and_then(Value::as_str)
1234        .unwrap_or_default()
1235        .to_string();
1236    let agent_chain = crate::json_util::string_array(payload, jwt_claims::AGENT_CHAIN);
1237
1238    let raw = object
1239        .iter()
1240        .map(|(key, value)| (key.clone(), value.clone()))
1241        .collect::<BTreeMap<_, _>>();
1242
1243    Ok(VerifiedClaims {
1244        sub: subject,
1245        client_id,
1246        scopes,
1247        issuer,
1248        audience,
1249        expires_at,
1250        issued_at,
1251        jti,
1252        kid: kid.to_string(),
1253        agent_id,
1254        agent_chain,
1255        not_before,
1256        raw,
1257        dpop_proof: None,
1258    })
1259}
1260
1261fn parse_audience(aud: Option<&Value>) -> Option<Vec<String>> {
1262    match aud {
1263        Some(Value::String(value)) if !value.is_empty() => Some(vec![value.to_string()]),
1264        Some(Value::Array(values)) => {
1265            let normalized = values
1266                .iter()
1267                .filter_map(Value::as_str)
1268                .filter(|value| !value.is_empty())
1269                .map(ToString::to_string)
1270                .collect::<Vec<_>>();
1271            if normalized.is_empty() {
1272                None
1273            } else {
1274                Some(normalized)
1275            }
1276        }
1277        _ => None,
1278    }
1279}
1280
1281fn required_string(payload: &Value, field: &str) -> Result<String, VerifierError> {
1282    payload
1283        .get(field)
1284        .and_then(Value::as_str)
1285        .filter(|value| !value.is_empty())
1286        .map(ToString::to_string)
1287        .ok_or_else(|| VerifierError::InvalidClaims {
1288            message: format!("token missing required '{field}' claim"),
1289        })
1290}
1291
1292fn required_i64(payload: &Value, field: &str) -> Result<i64, VerifierError> {
1293    payload
1294        .get(field)
1295        .and_then(Value::as_i64)
1296        .ok_or_else(|| VerifierError::InvalidClaims {
1297            message: format!("token missing required '{field}' claim"),
1298        })
1299}
1300
1301use crate::time_utils::unix_now_secs_i64 as unix_now;
1302
1303fn map_jwt_error(kind: &JwtErrorKind, kid: &str) -> VerifierError {
1304    match kind {
1305        JwtErrorKind::ExpiredSignature => VerifierError::TokenExpired,
1306        JwtErrorKind::InvalidAudience
1307        | JwtErrorKind::InvalidIssuer
1308        | JwtErrorKind::ImmatureSignature
1309        | JwtErrorKind::MissingRequiredClaim(_)
1310        | JwtErrorKind::InvalidAlgorithm => VerifierError::InvalidClaims {
1311            message: format!("{kind:?}"),
1312        },
1313        JwtErrorKind::InvalidSignature => VerifierError::InvalidSignature {
1314            message: format!("signature verification failed for kid {kid:?}"),
1315        },
1316        _ => VerifierError::InvalidSignature {
1317            message: format!("{kind:?}"),
1318        },
1319    }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use jsonwebtoken::jwk::JwkSet;
1325    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
1326    use serde_json::json;
1327
1328    use super::{
1329        AuthplaneResource, ResourceOptions, ResourceOptionsError, RevocationConfig,
1330        build_verified_claims, jwk_matches, parse_audience,
1331    };
1332    use crate::metadata::AuthorizationServerMetadata;
1333    use crate::{DpopProofOptions, DpopRequestContext, create_dpop_proof, jwk_thumbprint_sha256};
1334    use crate::{FetchSettings, VerifierError};
1335
1336    const TEST_JWKS: &str = r#"{
1337      "keys": [
1338        {
1339          "kty": "RSA",
1340          "kid": "test-kid",
1341          "alg": "RS256",
1342          "use": "sig",
1343          "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
1344          "e": "AQAB"
1345        }
1346      ]
1347    }"#;
1348    const TEST_PRIVATE_PEM: &str = include_str!("../tests/fixtures/test-private.pem");
1349
1350    fn auth_header() -> Header {
1351        Header {
1352            alg: Algorithm::RS256,
1353            kid: Some("test-kid".to_string()),
1354            typ: Some("at+jwt".to_string()),
1355            ..Header::new(Algorithm::RS256)
1356        }
1357    }
1358
1359    fn signed_token_with_claims(claims: serde_json::Value) -> String {
1360        encode(
1361            &auth_header(),
1362            &claims,
1363            &EncodingKey::from_rsa_pem(TEST_PRIVATE_PEM.as_bytes()).expect("private key"),
1364        )
1365        .expect("token")
1366    }
1367
1368    fn resource_with_test_jwks() -> AuthplaneResource {
1369        // Existing DPoP tests assume Mode 2 (DPoP supported, bearer also
1370        // accepted). Opt in here so the introduction of Mode 3 (the new
1371        // default when inbound_dpop is None) doesn't sweep through every
1372        // existing assertion. New tests below opt out (or in to Mode 1)
1373        // explicitly via the corresponding `resource_with_*` helper.
1374        resource_with_test_jwks_and_options(
1375            ResourceOptions::default().with_inbound_dpop(crate::InboundDPoPOptions::default()),
1376        )
1377    }
1378
1379    fn resource_with_test_jwks_and_options(options: ResourceOptions) -> AuthplaneResource {
1380        let metadata = AuthorizationServerMetadata {
1381            issuer: "https://auth.example.com".to_string(),
1382            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1383            token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1384            introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1385            revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1386        };
1387        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1388        AuthplaneResource::from_metadata_and_jwks(
1389            "https://auth.example.com",
1390            "https://api.example.com/mcp",
1391            &["tools/read".to_string()],
1392            metadata,
1393            FetchSettings::from_dev_mode(true),
1394            options,
1395            jwks,
1396        )
1397    }
1398
1399    #[test]
1400    fn build_verified_claims_rejects_future_iat() {
1401        let payload = json!({
1402            "iss": "https://auth.example.com",
1403            "sub": "user-1",
1404            "client_id": "client-1",
1405            "aud": "https://api.example.com",
1406            "exp": 4102444800i64,
1407            "iat": 4102444800i64,
1408            "jti": "token-1"
1409        });
1410
1411        let error = build_verified_claims(
1412            &payload,
1413            "test-kid",
1414            ResourceOptions::default().clock_skew_seconds,
1415        )
1416        .expect_err("iat must fail");
1417        assert!(matches!(error, VerifierError::InvalidClaims { .. }));
1418    }
1419
1420    #[test]
1421    fn jwk_selection_honors_kid_use_and_alg() {
1422        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1423        assert!(jwk_matches(
1424            &jwks.keys[0],
1425            "test-kid",
1426            jsonwebtoken::Algorithm::RS256
1427        ));
1428        assert!(!jwk_matches(
1429            &jwks.keys[0],
1430            "wrong-kid",
1431            jsonwebtoken::Algorithm::RS256
1432        ));
1433        assert!(!jwk_matches(
1434            &jwks.keys[0],
1435            "test-kid",
1436            jsonwebtoken::Algorithm::ES256
1437        ));
1438    }
1439
1440    #[test]
1441    fn resource_prm_uses_resource_and_issuer() {
1442        let metadata = AuthorizationServerMetadata {
1443            issuer: "https://auth.example.com".to_string(),
1444            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1445            token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1446            introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1447            revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1448        };
1449        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1450        let resource = AuthplaneResource::from_metadata_and_jwks(
1451            "https://auth.example.com",
1452            "https://api.example.com/mcp",
1453            &["tools/add".to_string()],
1454            metadata,
1455            FetchSettings::from_dev_mode(true),
1456            ResourceOptions::default(),
1457            jwks,
1458        );
1459
1460        let prm = resource.prm_response();
1461        assert_eq!(prm.resource, "https://api.example.com/mcp");
1462        assert_eq!(
1463            prm.authorization_servers,
1464            vec!["https://auth.example.com".to_string()]
1465        );
1466        assert_eq!(
1467            resource.prm_document_url().expect("valid prm document url"),
1468            "https://api.example.com/.well-known/oauth-protected-resource/mcp"
1469        );
1470    }
1471
1472    /// Drive `from_prefetched_metadata` (the sync constructor sharing the
1473    /// resource-identifier gate with `from_parts`) with an arbitrary
1474    /// resource string.
1475    fn try_construct_resource(resource: &str) -> Result<AuthplaneResource, VerifierError> {
1476        let metadata = AuthorizationServerMetadata {
1477            issuer: "https://auth.example.com".to_string(),
1478            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1479            token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1480            introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1481            revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1482        };
1483        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1484        AuthplaneResource::from_prefetched_metadata(
1485            "https://auth.example.com",
1486            resource,
1487            &["tools/read".to_string()],
1488            metadata,
1489            FetchSettings::from_dev_mode(true),
1490            ResourceOptions::default(),
1491            jwks,
1492        )
1493    }
1494
1495    fn assert_construction_rejects_resource(resource: &str, expected_message: &str) {
1496        let error = try_construct_resource(resource)
1497            .map(|_| ())
1498            .expect_err("construction must reject the resource identifier");
1499        let VerifierError::MetadataUnavailable { message } = error else {
1500            panic!("expected MetadataUnavailable, got {error:?}");
1501        };
1502        assert!(
1503            message.contains(expected_message),
1504            "unexpected message: {message}"
1505        );
1506    }
1507
1508    const ABSOLUTE_URL_MESSAGE: &str =
1509        "resource identifier must be an absolute URL with a scheme and a host";
1510
1511    #[test]
1512    fn construction_rejects_relative_resource_identifier() {
1513        // RFC 8707 §2 requires an absolute URI; the failure must surface
1514        // when the resource is built, not later when the 401-challenge
1515        // path first derives the PRM document URL.
1516        assert_construction_rejects_resource("/mcp", ABSOLUTE_URL_MESSAGE);
1517    }
1518
1519    #[test]
1520    fn construction_rejects_scheme_relative_resource_identifier() {
1521        // Carries an authority but no scheme — must reject on its own,
1522        // independent of the plain relative form.
1523        assert_construction_rejects_resource("//api.example.com/mcp", ABSOLUTE_URL_MESSAGE);
1524    }
1525
1526    #[test]
1527    fn construction_rejects_opaque_resource_identifier() {
1528        // Has no host to anchor the RFC 9728 §3 well-known insertion.
1529        assert_construction_rejects_resource("urn:example:api", ABSOLUTE_URL_MESSAGE);
1530    }
1531
1532    #[test]
1533    fn construction_rejects_fragment_bearing_resource_identifier() {
1534        // RFC 8707 §2 forbids a fragment outright; the raw-string check
1535        // must catch it because parsing splits the fragment off.
1536        assert_construction_rejects_resource(
1537            "https://api.example.com/mcp#v2",
1538            "resource identifier must not include a fragment component",
1539        );
1540    }
1541
1542    #[test]
1543    fn construction_rejects_userinfo_bearing_resource_identifier() {
1544        // RFC 9110 §4.2.4 — a credential in the identifier would be
1545        // served verbatim in the PRM `resource` member.
1546        assert_construction_rejects_resource(
1547            "https://svc:pw@api.example.com/mcp",
1548            "resource identifier must not include userinfo",
1549        );
1550    }
1551
1552    /// Drive `from_parts` — the production constructor funnel behind
1553    /// `create`/`create_with_options`/`AuthplaneClient::resource*` —
1554    /// directly: its resource-identifier gate runs before `fetch_jwks`,
1555    /// so with `jwks_cache: None` a bad resource must return `Err`
1556    /// without touching the network. The gate's message (rather than a
1557    /// JWKS fetch failure) proves the rejection came from validation.
1558    #[tokio::test]
1559    async fn from_parts_rejects_bad_resource_before_any_network_call() {
1560        let metadata = AuthorizationServerMetadata {
1561            issuer: "https://auth.example.com".to_string(),
1562            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1563            token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1564            introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1565            revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1566        };
1567        let fetch_settings = FetchSettings::from_dev_mode(true);
1568        let http =
1569            crate::transport::build_http_client(&fetch_settings).expect("valid fetch settings");
1570        let error = AuthplaneResource::from_parts(
1571            "https://auth.example.com".to_string(),
1572            "/mcp".to_string(),
1573            vec!["tools/read".to_string()],
1574            metadata,
1575            fetch_settings,
1576            ResourceOptions::default(),
1577            http,
1578            None,
1579            None,
1580            None,
1581        )
1582        .await
1583        .map(|_| ())
1584        .expect_err("from_parts must reject the resource identifier");
1585        let VerifierError::MetadataUnavailable { message } = error else {
1586            panic!("expected MetadataUnavailable, got {error:?}");
1587        };
1588        assert!(
1589            message.contains(ABSOLUTE_URL_MESSAGE),
1590            "unexpected message: {message}"
1591        );
1592    }
1593
1594    #[test]
1595    fn construction_accepts_http_localhost_resource() {
1596        // Scheme and host required, scheme not narrowed: plain-http local
1597        // development hosts stay constructible end-to-end.
1598        let resource = try_construct_resource("http://localhost:8080/mcp")
1599            .expect("http localhost resource must construct");
1600        assert_eq!(
1601            resource.prm_document_url().expect("valid prm document url"),
1602            "http://localhost:8080/.well-known/oauth-protected-resource/mcp"
1603        );
1604    }
1605
1606    #[test]
1607    fn revocation_config_can_be_constructed() {
1608        let config = RevocationConfig {
1609            client_id: "client-1".to_string(),
1610            client_secret: "secret-1".to_string(),
1611            fail_open: true,
1612        };
1613        assert!(config.fail_open);
1614    }
1615
1616    #[test]
1617    fn parse_audience_string_single_entry() {
1618        let aud = serde_json::json!("https://api.example.com");
1619        let parsed = parse_audience(Some(&aud)).expect("string aud must parse");
1620        assert_eq!(parsed, vec!["https://api.example.com".to_string()]);
1621    }
1622
1623    #[test]
1624    fn parse_audience_string_empty_is_none() {
1625        let aud = serde_json::json!("");
1626        assert!(parse_audience(Some(&aud)).is_none());
1627    }
1628
1629    #[test]
1630    fn parse_audience_array_multiple_entries() {
1631        let aud = serde_json::json!(["https://one.example", "https://two.example"]);
1632        let parsed = parse_audience(Some(&aud)).expect("array aud must parse");
1633        assert_eq!(
1634            parsed,
1635            vec![
1636                "https://one.example".to_string(),
1637                "https://two.example".to_string(),
1638            ]
1639        );
1640    }
1641
1642    #[test]
1643    fn parse_audience_array_filters_empty_entries() {
1644        let aud = serde_json::json!(["", "https://real.example", ""]);
1645        let parsed = parse_audience(Some(&aud)).expect("array aud must parse");
1646        assert_eq!(parsed, vec!["https://real.example".to_string()]);
1647    }
1648
1649    #[test]
1650    fn parse_audience_array_of_only_empty_strings_is_none() {
1651        let aud = serde_json::json!(["", ""]);
1652        assert!(parse_audience(Some(&aud)).is_none());
1653    }
1654
1655    #[test]
1656    fn parse_audience_array_of_non_strings_is_none() {
1657        // Per RFC 7519 `aud` must be string-valued; numeric-valued entries
1658        // must be ignored, not coerced.
1659        let aud = serde_json::json!([42, true]);
1660        assert!(parse_audience(Some(&aud)).is_none());
1661    }
1662
1663    #[test]
1664    fn parse_audience_object_is_none() {
1665        let aud = serde_json::json!({"not": "a valid aud"});
1666        assert!(parse_audience(Some(&aud)).is_none());
1667    }
1668
1669    #[test]
1670    fn parse_audience_missing_is_none() {
1671        assert!(parse_audience(None).is_none());
1672    }
1673
1674    #[test]
1675    fn build_verified_claims_accepts_array_audience_with_target_resource() {
1676        // RFC 8707 §2 — resource servers MUST accept tokens whose `aud` is
1677        // a list that contains the configured resource; the decoded
1678        // `audience` vector must preserve every array entry.
1679        let payload = json!({
1680            "iss": "https://auth.example.com",
1681            "sub": "user-1",
1682            "client_id": "client-1",
1683            "aud": ["https://api.example.com", "https://other.example.com"],
1684            "exp": 4102444800i64,
1685            "iat": 1700000000i64,
1686            "jti": "token-1"
1687        });
1688        let claims = build_verified_claims(
1689            &payload,
1690            "test-kid",
1691            ResourceOptions::default().clock_skew_seconds,
1692        )
1693        .expect("array aud must decode");
1694        assert_eq!(
1695            claims.audience,
1696            vec![
1697                "https://api.example.com".to_string(),
1698                "https://other.example.com".to_string(),
1699            ]
1700        );
1701    }
1702
1703    #[test]
1704    fn build_verified_claims_string_audience_becomes_single_element_vec() {
1705        let payload = json!({
1706            "iss": "https://auth.example.com",
1707            "sub": "user-1",
1708            "client_id": "client-1",
1709            "aud": "https://api.example.com",
1710            "exp": 4102444800i64,
1711            "iat": 1700000000i64,
1712            "jti": "token-1"
1713        });
1714        let claims = build_verified_claims(
1715            &payload,
1716            "test-kid",
1717            ResourceOptions::default().clock_skew_seconds,
1718        )
1719        .expect("string aud must decode");
1720        assert_eq!(claims.audience, vec!["https://api.example.com".to_string()]);
1721    }
1722
1723    #[test]
1724    fn build_verified_claims_rejects_empty_audience() {
1725        let payload = json!({
1726            "iss": "https://auth.example.com",
1727            "sub": "user-1",
1728            "client_id": "client-1",
1729            "aud": "",
1730            "exp": 4102444800i64,
1731            "iat": 1700000000i64,
1732            "jti": "token-1"
1733        });
1734        let error = build_verified_claims(
1735            &payload,
1736            "test-kid",
1737            ResourceOptions::default().clock_skew_seconds,
1738        )
1739        .expect_err("empty aud must fail");
1740        assert!(matches!(error, VerifierError::InvalidClaims { .. }));
1741    }
1742
1743    #[test]
1744    fn build_verified_claims_rejects_missing_jti() {
1745        let payload = json!({
1746            "iss": "https://auth.example.com",
1747            "sub": "user-1",
1748            "client_id": "client-1",
1749            "aud": "https://api.example.com",
1750            "exp": 4102444800i64,
1751            "iat": 1700000000i64
1752        });
1753        let error = build_verified_claims(
1754            &payload,
1755            "test-kid",
1756            ResourceOptions::default().clock_skew_seconds,
1757        )
1758        .expect_err("missing jti must fail");
1759        assert!(matches!(error, VerifierError::InvalidClaims { .. }));
1760    }
1761
1762    #[test]
1763    fn build_verified_claims_promotes_agent_id_and_chain() {
1764        let payload = json!({
1765            "iss": "https://auth.example.com",
1766            "sub": "user-1",
1767            "client_id": "client-1",
1768            "aud": "https://api.example.com",
1769            "exp": 4102444800i64,
1770            "iat": 1700000000i64,
1771            "jti": "token-1",
1772            "agent_id": "agent-007",
1773            "agent_chain": ["orchestrator", "agent-007"]
1774        });
1775        let claims = build_verified_claims(
1776            &payload,
1777            "test-kid",
1778            ResourceOptions::default().clock_skew_seconds,
1779        )
1780        .expect("agent claims must decode");
1781        assert_eq!(claims.agent_id, "agent-007");
1782        assert_eq!(
1783            claims.agent_chain,
1784            vec!["orchestrator".to_string(), "agent-007".to_string()]
1785        );
1786    }
1787
1788    #[test]
1789    fn build_verified_claims_defaults_agent_id_to_empty_when_absent() {
1790        let payload = json!({
1791            "iss": "https://auth.example.com",
1792            "sub": "user-1",
1793            "client_id": "client-1",
1794            "aud": "https://api.example.com",
1795            "exp": 4102444800i64,
1796            "iat": 1700000000i64,
1797            "jti": "token-1"
1798        });
1799        let claims = build_verified_claims(
1800            &payload,
1801            "test-kid",
1802            ResourceOptions::default().clock_skew_seconds,
1803        )
1804        .expect("bare token must decode");
1805        assert_eq!(claims.agent_id, "");
1806        assert!(claims.agent_chain.is_empty());
1807        assert_eq!(claims.not_before, 0);
1808    }
1809
1810    #[test]
1811    fn revocation_config_fail_closed_is_the_default() {
1812        // The `RevocationConfig` struct requires `fail_open` to be
1813        // explicit, but we document that the secure-by-default posture
1814        // is `fail_open = false`.
1815        let config = RevocationConfig {
1816            client_id: "client-1".to_string(),
1817            client_secret: "secret-1".to_string(),
1818            fail_open: false,
1819        };
1820        assert!(!config.fail_open, "default posture must be fail-closed");
1821    }
1822
1823    #[tokio::test]
1824    async fn verify_with_context_accepts_bearer_token_with_request_context_and_no_proof() {
1825        // Catalog:
1826        // `rfc9449-bearer-token-with-request-context-and-no-proof-must-still-verify-as-bearer`.
1827        // Bearer access token (no `cnf`) + request context supplied but
1828        // `proof = None` MUST still verify as bearer.
1829        let resource = resource_with_test_jwks();
1830        let token = signed_token_with_claims(json!({
1831            "iss": "https://auth.example.com",
1832            "sub": "user-1",
1833            "client_id": "client-1",
1834            "aud": "https://api.example.com/mcp",
1835            "exp": 4102444800i64,
1836            "iat": 1700000000i64,
1837            "jti": "token-1"
1838        }));
1839        let context = DpopRequestContext::new("GET", "https://api.example.com/mcp", None, None);
1840        let claims = resource
1841            .verify_with_context(&token, &context)
1842            .await
1843            .expect("bearer + request context must verify");
1844        assert_eq!(claims.client_id, "client-1");
1845        assert!(!claims.raw.contains_key("cnf"));
1846    }
1847
1848    /// The gap this trio closes: `verify` used to be the shared core, so a
1849    /// DPoP-bound token handed to the bearer-only entrypoint was verified and
1850    /// returned with its `cnf` never looked at — the sender constraint
1851    /// silently discarded. Nothing pinned that behaviour, which is why it
1852    /// survived. The `authplane-fastmcp` adapter is bearer-only by necessity
1853    /// (the upstream framework exposes no inbound HTTP context) and calls
1854    /// exactly this path.
1855    #[tokio::test]
1856    async fn verify_rejects_a_dpop_bound_token_in_mode_3() {
1857        // Mode 3 — resource has not opted into inbound DPoP. Same answer
1858        // `verify_with_context` gives for the same input.
1859        let resource = resource_with_test_jwks_and_options(ResourceOptions::default());
1860        let token = signed_token_with_claims(json!({
1861            "iss": "https://auth.example.com",
1862            "sub": "user-1",
1863            "client_id": "client-1",
1864            "aud": "https://api.example.com/mcp",
1865            "exp": 4102444800i64,
1866            "iat": 1700000000i64,
1867            "jti": "token-1",
1868            "cnf": { "jkt": "some-thumbprint" }
1869        }));
1870        let err = resource
1871            .verify(&token)
1872            .await
1873            .expect_err("a sender-constrained token must not pass as a bearer token");
1874        assert!(
1875            matches!(err, VerifierError::DpopNotSupported),
1876            "expected DpopNotSupported, got {err:?}"
1877        );
1878    }
1879
1880    #[tokio::test]
1881    async fn verify_rejects_a_dpop_bound_token_when_the_resource_supports_dpop() {
1882        // Modes 1 and 2 — the resource does support DPoP, so the token is
1883        // fine; what is missing is the proof, which this entrypoint cannot
1884        // supply. `resource_with_test_jwks` is Mode 2.
1885        let resource = resource_with_test_jwks();
1886        let token = signed_token_with_claims(json!({
1887            "iss": "https://auth.example.com",
1888            "sub": "user-1",
1889            "client_id": "client-1",
1890            "aud": "https://api.example.com/mcp",
1891            "exp": 4102444800i64,
1892            "iat": 1700000000i64,
1893            "jti": "token-1",
1894            "cnf": { "jkt": "some-thumbprint" }
1895        }));
1896        let err = resource
1897            .verify(&token)
1898            .await
1899            .expect_err("a bound token with no proof must not pass");
1900        // `DpopBindingMismatch`, not `DpopProofMissing`: this entrypoint takes
1901        // no request context, so "a context was supplied and held no proof" is
1902        // not what happened. `verify_with_context` owns that class.
1903        assert!(
1904            matches!(err, VerifierError::DpopBindingMismatch { .. }),
1905            "expected DpopBindingMismatch, got {err:?}"
1906        );
1907    }
1908
1909    #[tokio::test]
1910    async fn verify_still_accepts_a_plain_bearer_token() {
1911        // The regression guard for the two above: the guard must reject only
1912        // bound tokens, not every token.
1913        let resource = resource_with_test_jwks_and_options(ResourceOptions::default());
1914        let token = signed_token_with_claims(json!({
1915            "iss": "https://auth.example.com",
1916            "sub": "user-1",
1917            "client_id": "client-1",
1918            "aud": "https://api.example.com/mcp",
1919            "exp": 4102444800i64,
1920            "iat": 1700000000i64,
1921            "jti": "token-1"
1922        }));
1923        let claims = resource
1924            .verify(&token)
1925            .await
1926            .expect("a bearer token must still verify");
1927        assert_eq!(claims.sub, "user-1");
1928    }
1929
1930    #[tokio::test]
1931    async fn verify_with_context_rejects_dpop_bound_token_when_proof_is_missing() {
1932        // Catalog:
1933        // `rfc9449-dpop-bound-token-with-request-context-and-no-proof-must-be-rejected-via-main-verify-path`.
1934        // DPoP-bound token (`cnf.jkt` present) + request context WITHOUT
1935        // a proof MUST reject with `DpopProofMissing` ( error_category
1936        // "dpop_proof_missing" in the catalog).
1937        let resource = resource_with_test_jwks();
1938        let token = signed_token_with_claims(json!({
1939            "iss": "https://auth.example.com",
1940            "sub": "user-1",
1941            "client_id": "client-1",
1942            "aud": "https://api.example.com/mcp",
1943            "exp": 4102444800i64,
1944            "iat": 1700000000i64,
1945            "jti": "token-1",
1946            "cnf": { "jkt": "some-thumbprint" }
1947        }));
1948        let context = DpopRequestContext::new("GET", "https://api.example.com/mcp", None, None);
1949        let err = resource
1950            .verify_with_context(&token, &context)
1951            .await
1952            .expect_err("dpop-bound token + no proof must fail");
1953        assert!(
1954            matches!(err, VerifierError::DpopProofMissing),
1955            "expected DpopProofMissing, got {err:?}"
1956        );
1957    }
1958
1959    #[tokio::test]
1960    async fn verify_with_context_accepts_matching_dpop_bound_token_and_proof() {
1961        let resource = resource_with_test_jwks();
1962        let public_jwk = json!({
1963            "kty": "RSA",
1964            "kid": "test-kid",
1965            "use": "sig",
1966            "alg": "RS256",
1967            "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
1968            "e": "AQAB"
1969        });
1970        let jkt = jwk_thumbprint_sha256(&public_jwk).expect("jkt");
1971        let token = signed_token_with_claims(json!({
1972            "iss": "https://auth.example.com",
1973            "sub": "user-1",
1974            "client_id": "client-1",
1975            "aud": "https://api.example.com/mcp",
1976            "exp": 4102444800i64,
1977            "iat": 1700000000i64,
1978            "jti": "token-1",
1979            "cnf": { "jkt": jkt }
1980        }));
1981        let proof = create_dpop_proof(
1982            "POST",
1983            "https://api.example.com/mcp",
1984            Some(&token),
1985            &DpopProofOptions {
1986                private_key_pem: TEST_PRIVATE_PEM.to_string(),
1987                public_jwk,
1988                algorithm: Algorithm::RS256,
1989                key_id: Some("test-kid".to_string()),
1990                nonce: Some("nonce-1".to_string()),
1991                proof_ttl_seconds: None,
1992            },
1993        )
1994        .expect("proof");
1995        let context = DpopRequestContext::new(
1996            "POST",
1997            "https://api.example.com/mcp",
1998            Some(proof.as_str()),
1999            Some("nonce-1"),
2000        );
2001        let claims = resource
2002            .verify_with_context(&token, &context)
2003            .await
2004            .expect("matching dpop binding must verify");
2005        assert_eq!(claims.client_id, "client-1");
2006    }
2007
2008    #[tokio::test]
2009    async fn verify_with_context_rejects_cnf_with_missing_jkt() {
2010        // A token with `cnf` present but no `cnf.jkt` is structurally malformed
2011        // and must be rejected — we cannot fall through to bearer acceptance
2012        // because the AS signalled a binding.
2013        let resource = resource_with_test_jwks();
2014        let token = signed_token_with_claims(json!({
2015            "iss": "https://auth.example.com",
2016            "sub": "user-1",
2017            "client_id": "client-1",
2018            "aud": "https://api.example.com/mcp",
2019            "exp": 4102444800i64,
2020            "iat": 1700000000i64,
2021            "jti": "token-1",
2022            "cnf": { "x5t#S256": "unrelated" }
2023        }));
2024        let context = DpopRequestContext::new("GET", "https://api.example.com/mcp", None, None);
2025        let err = resource
2026            .verify_with_context(&token, &context)
2027            .await
2028            .expect_err("cnf without jkt must be rejected");
2029        match err {
2030            VerifierError::InvalidClaims { message } => assert!(
2031                message.to_ascii_lowercase().contains("cnf.jkt"),
2032                "error must call out cnf.jkt, got {message}"
2033            ),
2034            other => panic!("expected InvalidClaims, got {other:?}"),
2035        }
2036    }
2037
2038    #[tokio::test]
2039    async fn verify_with_context_rejects_cnf_jkt_mismatch_as_invalid_claims() {
2040        let resource = resource_with_test_jwks();
2041        let token = signed_token_with_claims(json!({
2042            "iss": "https://auth.example.com",
2043            "sub": "user-1",
2044            "client_id": "client-1",
2045            "aud": "https://api.example.com/mcp",
2046            "exp": 4102444800i64,
2047            "iat": 1700000000i64,
2048            "jti": "token-1",
2049            "cnf": { "jkt": "wrong-thumbprint" }
2050        }));
2051        let proof = create_dpop_proof(
2052            "POST",
2053            "https://api.example.com/mcp",
2054            Some(&token),
2055            &DpopProofOptions {
2056                private_key_pem: TEST_PRIVATE_PEM.to_string(),
2057                public_jwk: json!({
2058                    "kty": "RSA",
2059                    "kid": "test-kid",
2060                    "use": "sig",
2061                    "alg": "RS256",
2062                    "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
2063                    "e": "AQAB"
2064                }),
2065                algorithm: Algorithm::RS256,
2066                key_id: Some("test-kid".to_string()),
2067                nonce: None,
2068                proof_ttl_seconds: None,
2069            },
2070        )
2071        .expect("proof");
2072        let context = DpopRequestContext::new(
2073            "POST",
2074            "https://api.example.com/mcp",
2075            Some(proof.as_str()),
2076            None,
2077        );
2078        let err = resource
2079            .verify_with_context(&token, &context)
2080            .await
2081            .expect_err("mismatched cnf.jkt must be rejected");
2082        assert!(
2083            err.to_string().contains("cnf.jkt mismatch"),
2084            "expected cnf.jkt mismatch error, got {err:?}"
2085        );
2086    }
2087
2088    const PRM_URL: &str = "https://api.example.com/.well-known/oauth-protected-resource/mcp";
2089
2090    fn prefetched_resource(options: ResourceOptions) -> Result<AuthplaneResource, VerifierError> {
2091        let metadata = AuthorizationServerMetadata {
2092            issuer: "https://auth.example.com".to_string(),
2093            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2094            token_endpoint: None,
2095            introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
2096            revocation_endpoint: None,
2097        };
2098        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2099        AuthplaneResource::from_prefetched_metadata(
2100            "https://auth.example.com",
2101            "https://api.example.com/mcp",
2102            &["tools/read".to_string()],
2103            metadata,
2104            FetchSettings::from_dev_mode(true),
2105            options,
2106            jwks,
2107        )
2108    }
2109
2110    /// RFC 9728 §5.1 — with no override the challenge advertises the
2111    /// §3.1 derivation, the same URL `prm_document_url` returns.
2112    #[test]
2113    fn resource_metadata_url_defaults_to_the_derived_prm_document_url() {
2114        let resource = resource_with_test_jwks();
2115        assert_eq!(resource.resource_metadata_url(), PRM_URL);
2116        assert_eq!(
2117            resource.prm_document_url().expect("derivable"),
2118            resource.resource_metadata_url()
2119        );
2120        let header = resource.www_authenticate(&VerifierError::TokenExpired, "api");
2121        assert_eq!(
2122            header,
2123            format!(
2124                "Bearer realm=\"api\", error=\"invalid_token\", \
2125                 error_description=\"token has expired\", resource_metadata=\"{PRM_URL}\""
2126            )
2127        );
2128    }
2129
2130    #[test]
2131    fn resource_metadata_url_override_is_advertised_instead_of_the_derivation() {
2132        let custom = "https://auth.example.com/.well-known/oauth-protected-resource/calc";
2133        let options = ResourceOptions::default()
2134            .with_resource_metadata_url(custom)
2135            .expect("absolute URL");
2136        assert_eq!(options.resource_metadata_url(), Some(custom));
2137        let resource = resource_with_test_jwks_and_options(options);
2138        assert_eq!(resource.resource_metadata_url(), custom);
2139        // The derivation is untouched: it still names where this server
2140        // would host its own document.
2141        assert_eq!(resource.prm_document_url().expect("derivable"), PRM_URL);
2142        let header = resource.www_authenticate(&VerifierError::TokenMissing, "");
2143        assert!(header.ends_with(&format!("resource_metadata=\"{custom}\"")));
2144    }
2145
2146    #[test]
2147    fn with_resource_metadata_url_rejects_non_absolute_urls() {
2148        for bad in ["/.well-known/oauth-protected-resource/mcp", "not a url", ""] {
2149            let error = ResourceOptions::default()
2150                .with_resource_metadata_url(bad)
2151                .err()
2152                .unwrap_or_else(|| panic!("{bad:?} must be rejected"));
2153            assert!(
2154                matches!(&error, ResourceOptionsError::InvalidResourceMetadataUrl(got) if got == bad),
2155                "unexpected error for {bad:?}: {error}"
2156            );
2157        }
2158    }
2159
2160    /// The WHATWG parser trims leading and trailing C0/space and removes tab
2161    /// and newline anywhere before parsing, so every one of these parses with
2162    /// a host. The value is stored and advertised as typed, so a CR or LF
2163    /// makes `HeaderValue::from_str` fail and drops `WWW-Authenticate` from
2164    /// every `401` — silently, since the adapters have no error path there.
2165    #[test]
2166    fn with_resource_metadata_url_rejects_octets_the_header_cannot_carry() {
2167        for bad in [
2168            "https://auth.example.com/prm\n",
2169            "https://auth.example.com/prm\r\n",
2170            " https://auth.example.com/prm",
2171            "https://auth.example.com/prm\tx",
2172            "https://auth.example.com/prm doc",
2173            "https://auth.example.com/prm\u{7f}",
2174            "https://auth.example.com/prm\"x",
2175            "https://auth.example.com/prm\\x",
2176        ] {
2177            let error = ResourceOptions::default()
2178                .with_resource_metadata_url(bad)
2179                .err()
2180                .unwrap_or_else(|| panic!("{bad:?} must be rejected"));
2181            assert!(
2182                matches!(&error, ResourceOptionsError::InvalidResourceMetadataUrl(got) if got == bad),
2183                "unexpected error for {bad:?}: {error}"
2184            );
2185        }
2186    }
2187
2188    /// authserver >= 0.1.2 answers `active: false` to unauthenticated
2189    /// introspection, so a resource built with empty credentials would
2190    /// reject every token as revoked. Refuse it at construction.
2191    #[test]
2192    fn construction_rejects_revocation_config_with_empty_credentials() {
2193        for (client_id, client_secret) in [("", "secret"), ("rs-client", ""), ("rs-client", "  ")] {
2194            let options = ResourceOptions {
2195                revocation: Some(RevocationConfig {
2196                    client_id: client_id.to_string(),
2197                    client_secret: client_secret.to_string(),
2198                    fail_open: false,
2199                }),
2200                ..ResourceOptions::default()
2201            };
2202            let error = prefetched_resource(options).expect_err("empty credentials must fail");
2203            let VerifierError::MetadataUnavailable { message } = &error else {
2204                panic!("expected MetadataUnavailable, got {error:?}");
2205            };
2206            assert!(message.contains("confidential client"), "{message}");
2207            assert!(message.contains("authserver >= 0.1.2"), "{message}");
2208        }
2209    }
2210
2211    #[test]
2212    fn construction_accepts_revocation_config_with_credentials() {
2213        let options = ResourceOptions {
2214            revocation: Some(RevocationConfig {
2215                client_id: "rs-client".to_string(),
2216                client_secret: "rs-secret".to_string(),
2217                fail_open: false,
2218            }),
2219            ..ResourceOptions::default()
2220        };
2221        prefetched_resource(options).expect("credentials present");
2222    }
2223
2224    #[test]
2225    fn with_allowed_algorithms_accepts_asymmetric_subset() {
2226        let opts = ResourceOptions::default()
2227            .with_allowed_algorithms(vec![Algorithm::ES256])
2228            .expect("ES256 is asymmetric, must be accepted");
2229        assert_eq!(opts.allowed_algorithms(), &[Algorithm::ES256]);
2230    }
2231
2232    #[test]
2233    fn with_allowed_algorithms_rejects_empty_list() {
2234        let err = ResourceOptions::default()
2235            .with_allowed_algorithms(Vec::new())
2236            .expect_err("empty list must be rejected");
2237        assert!(matches!(
2238            err,
2239            super::ResourceOptionsError::EmptyAlgorithmList
2240        ));
2241    }
2242
2243    #[test]
2244    fn with_allowed_algorithms_rejects_hmac() {
2245        // RFC 7518 §3.2 HS256: a JWKS rotation that ships a symmetric key
2246        // as a public JWK would let an attacker forge tokens (algorithm
2247        // confusion). Construction-time rejection beats verify-time
2248        // rejection because it makes the misconfiguration impossible to
2249        // ship.
2250        for hmac in [Algorithm::HS256, Algorithm::HS384, Algorithm::HS512] {
2251            let err = ResourceOptions::default()
2252                .with_allowed_algorithms(vec![hmac])
2253                .expect_err("HMAC must be rejected at construction");
2254            assert!(
2255                matches!(err, super::ResourceOptionsError::UnsupportedAlgorithm(alg) if alg == hmac),
2256                "expected UnsupportedAlgorithm({hmac:?}), got {err:?}"
2257            );
2258        }
2259    }
2260
2261    #[test]
2262    fn with_allowed_algorithms_rejects_mixed_list_when_any_hmac() {
2263        // A single HMAC entry in an otherwise-asymmetric list MUST poison
2264        // the whole call — silently dropping it would silently relax the
2265        // user's stated intent.
2266        let err = ResourceOptions::default()
2267            .with_allowed_algorithms(vec![Algorithm::RS256, Algorithm::HS256])
2268            .expect_err("mixed list with HMAC must be rejected");
2269        assert!(matches!(
2270            err,
2271            super::ResourceOptionsError::UnsupportedAlgorithm(Algorithm::HS256)
2272        ));
2273    }
2274
2275    #[test]
2276    fn with_allowed_algorithms_rejects_non_contract_asymmetric_variants() {
2277        // The access-token contract allowlists exactly {RS256, ES256};
2278        // the additional asymmetric variants `jsonwebtoken` exposes are
2279        // not part of it. Accepting them here would let a caller advertise
2280        // an alg in their PRM / JWKS that peers can't validate and
2281        // broaden the algorithm-confusion surface beyond that contract.
2282        // Each non-contract variant must fail at construction with
2283        // `UnsupportedAlgorithm`.
2284        for alg in [
2285            Algorithm::RS384,
2286            Algorithm::RS512,
2287            Algorithm::PS256,
2288            Algorithm::PS384,
2289            Algorithm::PS512,
2290            Algorithm::ES384,
2291            Algorithm::EdDSA,
2292        ] {
2293            let err = ResourceOptions::default()
2294                .with_allowed_algorithms(vec![alg])
2295                .expect_err("non-contract asymmetric alg must be rejected");
2296            assert!(
2297                matches!(err, super::ResourceOptionsError::UnsupportedAlgorithm(rejected) if rejected == alg),
2298                "expected UnsupportedAlgorithm({alg:?}), got {err:?}"
2299            );
2300        }
2301    }
2302
2303    #[test]
2304    fn with_allowed_algorithms_rejects_mixed_list_when_any_non_contract() {
2305        // Same poisoning semantics as the HMAC case, but for the
2306        // non-contract asymmetric variants. A list of `[RS256, RS384]` is
2307        // not the user expressing "accept the union" — it's the user
2308        // expressing intent we can't honour, so we surface the offending
2309        // entry instead of silently dropping it.
2310        let err = ResourceOptions::default()
2311            .with_allowed_algorithms(vec![Algorithm::RS256, Algorithm::RS384])
2312            .expect_err("mixed list with non-contract alg must be rejected");
2313        assert!(matches!(
2314            err,
2315            super::ResourceOptionsError::UnsupportedAlgorithm(Algorithm::RS384)
2316        ));
2317    }
2318
2319    #[tokio::test]
2320    async fn verify_with_context_rejects_replayed_proof() {
2321        // Regression coverage: `InboundDPoPOptions::default()` used to leave
2322        // `replay_store = None`, which silently fell through to the
2323        // no-replay path. Auto-allocation now wires an in-memory store by
2324        // default — confirm that a second verify with the same proof + jti
2325        // hits `DpopReplayDetected`, proving the with-replay branch is the
2326        // one being exercised.
2327        let resource = resource_with_test_jwks();
2328        let public_jwk = json!({
2329            "kty": "RSA",
2330            "kid": "test-kid",
2331            "use": "sig",
2332            "alg": "RS256",
2333            "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
2334            "e": "AQAB"
2335        });
2336        let jkt = jwk_thumbprint_sha256(&public_jwk).expect("jkt");
2337        let token = signed_token_with_claims(json!({
2338            "iss": "https://auth.example.com",
2339            "sub": "user-1",
2340            "client_id": "client-1",
2341            "aud": "https://api.example.com/mcp",
2342            "exp": 4102444800i64,
2343            "iat": 1700000000i64,
2344            "jti": "token-replay-1",
2345            "cnf": { "jkt": jkt }
2346        }));
2347        let proof = create_dpop_proof(
2348            "POST",
2349            "https://api.example.com/mcp",
2350            Some(&token),
2351            &DpopProofOptions {
2352                private_key_pem: TEST_PRIVATE_PEM.to_string(),
2353                public_jwk,
2354                algorithm: Algorithm::RS256,
2355                key_id: Some("test-kid".to_string()),
2356                nonce: Some("nonce-replay".to_string()),
2357                proof_ttl_seconds: None,
2358            },
2359        )
2360        .expect("proof");
2361        let context = DpopRequestContext::new(
2362            "POST",
2363            "https://api.example.com/mcp",
2364            Some(proof.as_str()),
2365            Some("nonce-replay"),
2366        );
2367        resource
2368            .verify_with_context(&token, &context)
2369            .await
2370            .expect("first verify must succeed");
2371        let err = resource
2372            .verify_with_context(&token, &context)
2373            .await
2374            .expect_err("replayed proof must be rejected");
2375        assert!(
2376            matches!(err, VerifierError::DpopReplayDetected),
2377            "expected DpopReplayDetected on replay, got {err:?}"
2378        );
2379    }
2380
2381    fn prm_resource_with_options(options: ResourceOptions) -> AuthplaneResource {
2382        let metadata = AuthorizationServerMetadata {
2383            issuer: "https://auth.example.com".to_string(),
2384            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2385            token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
2386            introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
2387            revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
2388        };
2389        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2390        AuthplaneResource::from_metadata_and_jwks(
2391            "https://auth.example.com",
2392            "https://api.example.com/mcp",
2393            &["tools/read".to_string()],
2394            metadata,
2395            FetchSettings::from_dev_mode(true),
2396            options,
2397            jwks,
2398        )
2399    }
2400
2401    #[test]
2402    fn prm_response_mode_3_omits_dpop_fields() {
2403        // Mode 3: ResourceOptions::default() has inbound_dpop = None,
2404        // so the PRM document MUST omit both dpop_* fields entirely
2405        // (RFC 9728 §2 — absence signals "no DPoP capability").
2406        let resource = prm_resource_with_options(ResourceOptions::default());
2407        let prm = resource.prm_response();
2408        assert!(
2409            prm.dpop_signing_alg_values_supported.is_none(),
2410            "Mode 3 PRM must not advertise DPoP algs"
2411        );
2412        assert!(
2413            prm.dpop_bound_access_tokens_required.is_none(),
2414            "Mode 3 PRM must not advertise dpop_bound_access_tokens_required"
2415        );
2416    }
2417
2418    #[test]
2419    fn prm_response_mode_2_advertises_dpop_optional() {
2420        // Mode 2: inbound_dpop = InboundDPoPOptions::default() means
2421        // DPoP-bound tokens are accepted but not required. PRM advertises
2422        // the supported proof algs AND `dpop_bound_access_tokens_required: false`.
2423        let resource = prm_resource_with_options(
2424            ResourceOptions::default().with_inbound_dpop(crate::InboundDPoPOptions::default()),
2425        );
2426        let prm = resource.prm_response();
2427        assert_eq!(
2428            prm.dpop_signing_alg_values_supported,
2429            Some(vec!["ES256".to_string(), "RS256".to_string()]),
2430            "Mode 2 PRM must list the default proof algs in the documented canonical order [ES256, RS256]"
2431        );
2432        assert_eq!(prm.dpop_bound_access_tokens_required, Some(false));
2433    }
2434
2435    #[test]
2436    fn prm_response_mode_1_advertises_dpop_required() {
2437        // Mode 1: inbound_dpop = InboundDPoPOptions::required() — bearer-
2438        // only tokens are rejected, PRM tells well-behaved clients to
2439        // retry with a DPoP-bound token.
2440        let resource = prm_resource_with_options(
2441            ResourceOptions::default().with_inbound_dpop(crate::InboundDPoPOptions::required()),
2442        );
2443        let prm = resource.prm_response();
2444        assert_eq!(
2445            prm.dpop_signing_alg_values_supported,
2446            Some(vec!["ES256".to_string(), "RS256".to_string()]),
2447        );
2448        assert_eq!(prm.dpop_bound_access_tokens_required, Some(true));
2449    }
2450
2451    #[test]
2452    fn prm_response_respects_custom_proof_alg_subset() {
2453        // When the resource narrows the accepted proof algs (e.g. to
2454        // ES256 only), the PRM document must reflect that exact subset
2455        // — clients picking RS256 would just get a verify-time rejection.
2456        let inbound = crate::InboundDPoPOptions::default()
2457            .with_allowed_proof_algorithms(vec![Algorithm::ES256])
2458            .expect("ES256 subset is valid");
2459        let resource =
2460            prm_resource_with_options(ResourceOptions::default().with_inbound_dpop(inbound));
2461        let prm = resource.prm_response();
2462        assert_eq!(
2463            prm.dpop_signing_alg_values_supported,
2464            Some(vec!["ES256".to_string()]),
2465        );
2466        assert_eq!(prm.dpop_bound_access_tokens_required, Some(false));
2467    }
2468
2469    #[test]
2470    fn dpop_proof_max_age_independent_of_clock_skew() {
2471        // Raising clock_skew_seconds must NOT extend the DPoP proof
2472        // acceptance window beyond dpop_proof_max_age_seconds.
2473        let default = ResourceOptions::default();
2474        assert_eq!(default.clock_skew_seconds, 30);
2475        assert_eq!(default.dpop_proof_max_age_seconds, 300);
2476
2477        let custom = ResourceOptions {
2478            clock_skew_seconds: 600,         // high clock skew
2479            dpop_proof_max_age_seconds: 120, // tight DPoP proof window
2480            ..ResourceOptions::default()
2481        };
2482        // The DPoP proof TTL must remain 120, not get inflated to 600.
2483        assert_eq!(custom.dpop_proof_max_age_seconds, 120);
2484        assert_eq!(custom.clock_skew_seconds, 600);
2485        // Before the fix, max_age_seconds was computed as
2486        // `clock_skew_seconds.max(300)` = 600, effectively overriding
2487        // any intended DPoP proof window. Now it's a separate field.
2488    }
2489
2490    // -------------------------------------------------------------------
2491    // Circuit-breaker short-circuit behaviour on the revocation path
2492    // -------------------------------------------------------------------
2493    //
2494    // These tests cover the new branches introduced when introspection was
2495    // routed through the shared `CircuitBreaker`: when the breaker is OPEN
2496    // we never make the HTTP round-trip; whether the token is accepted or
2497    // rejected depends on `RevocationConfig::fail_open`. The breaker-OPEN
2498    // path is the one that doesn't hit `self.http` at all, so we can test
2499    // it without an HTTP mock — the closed-and-call-introspect path needs
2500    // a wiremock-style harness and is left for the integration suite.
2501
2502    use std::sync::Arc;
2503
2504    use crate::CircuitBreaker;
2505
2506    fn revocation_resource_with_breaker(
2507        fail_open: bool,
2508        breaker: Arc<CircuitBreaker>,
2509    ) -> AuthplaneResource {
2510        let metadata = AuthorizationServerMetadata {
2511            issuer: "https://auth.example.com".to_string(),
2512            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2513            token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
2514            introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
2515            revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
2516        };
2517        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2518        let options = ResourceOptions {
2519            revocation: Some(RevocationConfig {
2520                client_id: "client-1".to_string(),
2521                client_secret: "secret-1".to_string(),
2522                fail_open,
2523            }),
2524            ..ResourceOptions::default()
2525        };
2526        AuthplaneResource::from_metadata_jwks_and_breaker(
2527            "https://auth.example.com",
2528            "https://api.example.com/mcp",
2529            &["tools/read".to_string()],
2530            metadata,
2531            FetchSettings::from_dev_mode(true),
2532            options,
2533            jwks,
2534            breaker,
2535        )
2536    }
2537
2538    fn token_for_revocation_branch() -> String {
2539        signed_token_with_claims(json!({
2540            "iss": "https://auth.example.com",
2541            "sub": "user-1",
2542            "client_id": "client-1",
2543            "aud": "https://api.example.com/mcp",
2544            "exp": 4_102_444_800i64,
2545            "iat": 1_700_000_000i64,
2546            "jti": "token-1"
2547        }))
2548    }
2549
2550    fn opened_breaker() -> Arc<CircuitBreaker> {
2551        let breaker = CircuitBreaker::with_config(1, 60.0);
2552        breaker.record_failure();
2553        // Sanity-check: the breaker is OPEN before we hand it to the resource,
2554        // so `breaker.allow()` will return false on the first verify call and
2555        // the short-circuit branch fires.
2556        assert!(
2557            !breaker.allow(),
2558            "breaker must be open before the test runs"
2559        );
2560        Arc::new(breaker)
2561    }
2562
2563    #[tokio::test]
2564    async fn verify_with_open_breaker_and_fail_open_accepts_token_without_introspection() {
2565        // Branch: revocation configured + breaker OPEN + fail_open=true.
2566        // Expected: `verify` returns Ok without calling the introspection
2567        // endpoint. (If it did, the call would hit the unreachable AS host
2568        // and either time out or return a transport error.)
2569        let resource = revocation_resource_with_breaker(true, opened_breaker());
2570        let token = token_for_revocation_branch();
2571        let claims = resource
2572            .verify(&token)
2573            .await
2574            .expect("open breaker + fail-open must accept the token");
2575        assert_eq!(claims.sub, "user-1");
2576    }
2577
2578    #[tokio::test]
2579    async fn verify_with_open_breaker_and_fail_closed_surfaces_metadata_unavailable() {
2580        // Branch: revocation configured + breaker OPEN + fail_open=false.
2581        // Expected: `verify` returns MetadataUnavailable with a message
2582        // naming the circuit breaker, never touching the AS.
2583        let resource = revocation_resource_with_breaker(false, opened_breaker());
2584        let token = token_for_revocation_branch();
2585        let error = resource
2586            .verify(&token)
2587            .await
2588            .expect_err("open breaker + fail-closed must reject");
2589        match error {
2590            VerifierError::MetadataUnavailable { message } => {
2591                assert!(
2592                    message.contains("circuit breaker"),
2593                    "expected the message to name the breaker, got: {message}"
2594                );
2595            }
2596            other => panic!("expected MetadataUnavailable, got {other:?}"),
2597        }
2598    }
2599
2600    // -------------------------------------------------------------------
2601    // Failure-accounting tests — cover the introspection-call path (not
2602    // just the breaker-already-open short-circuit). The reviewer flagged
2603    // that the prior set only tested OPEN-branch behaviour, so a
2604    // regression where `record_failure()` is called on a benign OAuth
2605    // error (e.g. `invalid_client`) would not be caught. The shared
2606    // `circuit_policy::should_count_failure` predicate is what gates the
2607    // counting; these tests exercise both halves of that gate from the
2608    // resource-side introspection path.
2609    // -------------------------------------------------------------------
2610
2611    fn revocation_resource_pointing_at(
2612        introspection_url: &str,
2613        fail_open: bool,
2614        breaker: Arc<CircuitBreaker>,
2615    ) -> AuthplaneResource {
2616        let metadata = AuthorizationServerMetadata {
2617            issuer: "https://auth.example.com".to_string(),
2618            jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2619            token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
2620            introspection_endpoint: Some(introspection_url.to_string()),
2621            revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
2622        };
2623        let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2624        let options = ResourceOptions {
2625            revocation: Some(RevocationConfig {
2626                client_id: "client-1".to_string(),
2627                client_secret: "secret-1".to_string(),
2628                fail_open,
2629            }),
2630            ..ResourceOptions::default()
2631        };
2632        AuthplaneResource::from_metadata_jwks_and_breaker(
2633            "https://auth.example.com",
2634            "https://api.example.com/mcp",
2635            &["tools/read".to_string()],
2636            metadata,
2637            FetchSettings::from_dev_mode(true),
2638            options,
2639            jwks,
2640            breaker,
2641        )
2642    }
2643
2644    #[tokio::test]
2645    async fn introspection_invalid_client_does_not_trip_breaker() {
2646        // The introspection endpoint returns the OAuth error that lives
2647        // in `circuit_policy::OAUTH_ERRORS_NO_CIRCUIT`. With fail_open=true
2648        // the verify call still succeeds; the breaker MUST remain Closed
2649        // even after enough failed introspection round-trips to cross the
2650        // configured threshold. This is the asymmetry the audit was
2651        // worried about — `AuthplaneClient::run_guarded` ignores
2652        // `invalid_client` for breaker accounting and the resource path
2653        // must do the same, otherwise misconfigured introspection creds
2654        // silently disable revocation checks for the cooldown window.
2655        let mut server = mockito::Server::new_async().await;
2656        let introspection_url = format!("{}/oauth/introspect", server.url());
2657        let _mock = server
2658            .mock("POST", "/oauth/introspect")
2659            .with_status(401)
2660            .with_header("content-type", "application/json")
2661            .with_body(r#"{"error":"invalid_client","error_description":"bad creds"}"#)
2662            .expect_at_least(3)
2663            .create_async()
2664            .await;
2665
2666        let breaker = Arc::new(CircuitBreaker::with_config(2, 60.0));
2667        let resource = revocation_resource_pointing_at(&introspection_url, true, breaker.clone());
2668        let token = token_for_revocation_branch();
2669
2670        // 3 calls > threshold of 2 — if the resource counted these against
2671        // the breaker, it would already be Open by the third iteration.
2672        for _ in 0..3 {
2673            resource
2674                .verify(&token)
2675                .await
2676                .expect("fail_open swallows the introspection error");
2677        }
2678
2679        assert!(
2680            breaker.allow(),
2681            "breaker must still allow traffic after benign OAuth errors"
2682        );
2683    }
2684
2685    #[tokio::test]
2686    async fn introspection_server_error_does_trip_breaker() {
2687        // Counter-test: `server_error` is NOT in OAUTH_ERRORS_NO_CIRCUIT,
2688        // so it MUST count against the breaker. Without this assertion the
2689        // first test could pass trivially by a `should_count_failure` that
2690        // returned `false` for every input.
2691        let mut server = mockito::Server::new_async().await;
2692        let introspection_url = format!("{}/oauth/introspect", server.url());
2693        let _mock = server
2694            .mock("POST", "/oauth/introspect")
2695            .with_status(500)
2696            .with_header("content-type", "application/json")
2697            .with_body(r#"{"error":"server_error","error_description":"boom"}"#)
2698            .expect_at_least(2)
2699            .create_async()
2700            .await;
2701
2702        let breaker = Arc::new(CircuitBreaker::with_config(2, 60.0));
2703        let resource = revocation_resource_pointing_at(&introspection_url, true, breaker.clone());
2704        let token = token_for_revocation_branch();
2705
2706        for _ in 0..2 {
2707            resource
2708                .verify(&token)
2709                .await
2710                .expect("fail_open swallows the introspection error");
2711        }
2712
2713        assert!(
2714            !breaker.allow(),
2715            "breaker must be Open after threshold genuine AS failures"
2716        );
2717    }
2718
2719    // ------------------------------------------------------------------
2720    // jwks_uri rotation discovered by a `kid` miss
2721    // ------------------------------------------------------------------
2722
2723    /// The shipped RSA test key, republished under an arbitrary `kid`.
2724    ///
2725    /// Only the `kid` distinguishes the two JWKS documents in the rotation
2726    /// test below, which is enough: the pre-rotation document never carries
2727    /// the post-rotation `kid`, so the lookup can only succeed by fetching
2728    /// the document published at the rotated `jwks_uri`.
2729    fn jwks_document_with_kid(kid: &str) -> serde_json::Value {
2730        let mut document: serde_json::Value =
2731            serde_json::from_str(TEST_JWKS).expect("valid jwks fixture");
2732        document["keys"][0]["kid"] = json!(kid);
2733        document
2734    }
2735
2736    /// A second RSA key pair with material distinct from `TEST_PRIVATE_PEM`,
2737    /// for rotations that republish under the **same** `kid`.
2738    const TEST_PRIVATE_PEM_2: &str = include_str!("../tests/fixtures/test-private-2.pem");
2739    const TEST_MODULUS_2: &str = "xRkG21gLFZsQn9F9D-UAevGbhdQFw1htbfWdtviNQTO3jjyk3vg273zrbNcs_-KrhtTRcLlJQWgtXuSakzdw472PvGsDph8k1v_XDj_jZztXXj6K9-G_ntAFigT55CdRXw7DzjoJKbDMgIyaVASAlvOi7Vdz39iWn5BlGU4GhUyjgKJHoUee5jCWWN0c2A-N0RclR77JVbptEi8DUZ5P2UjoW1n26pkyP4Pmy2zjBlAj6S7jm7BayeiMvaaTRy_esqjzRxh8D62BbUtFpZ-Og60HXayUqjJBOnR4Pt5d515e9BiBVqWLe7hkG0TPA6fweOxN96VL50Bf24mUPPvx9Q";
2740
2741    /// The second key's JWKS, published under an arbitrary `kid`.
2742    fn jwks_document_with_kid_and_second_key(kid: &str) -> serde_json::Value {
2743        let mut document: serde_json::Value =
2744            serde_json::from_str(TEST_JWKS).expect("valid jwks fixture");
2745        document["keys"][0]["kid"] = json!(kid);
2746        document["keys"][0]["n"] = json!(TEST_MODULUS_2);
2747        document
2748    }
2749
2750    fn signed_token_with_kid_and_key(
2751        kid: &str,
2752        private_pem: &str,
2753        issuer: &str,
2754        audience: &str,
2755        jti: &str,
2756    ) -> String {
2757        let now = std::time::SystemTime::now()
2758            .duration_since(std::time::UNIX_EPOCH)
2759            .expect("system clock after the epoch")
2760            .as_secs() as i64;
2761        let claims = json!({
2762            "iss": issuer,
2763            "sub": "user-1",
2764            "client_id": "client-1",
2765            "aud": audience,
2766            "jti": jti,
2767            "iat": now,
2768            "exp": now + 300,
2769            "scope": "tools/read"
2770        });
2771        let header = Header {
2772            alg: Algorithm::RS256,
2773            kid: Some(kid.to_string()),
2774            typ: Some("at+jwt".to_string()),
2775            ..Header::new(Algorithm::RS256)
2776        };
2777        encode(
2778            &header,
2779            &claims,
2780            &EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("private key"),
2781        )
2782        .expect("token")
2783    }
2784
2785    fn signed_token_with_kid(kid: &str, issuer: &str, audience: &str, jti: &str) -> String {
2786        signed_token_with_kid_and_key(kid, TEST_PRIVATE_PEM, issuer, audience, jti)
2787    }
2788
2789    #[tokio::test]
2790    async fn kid_miss_re_reads_metadata_without_waiting_for_the_refresh_interval() {
2791        // The interval gate is set far out of reach (one hour) and the JWKS
2792        // TTL with it, so nothing in this test can re-read metadata on a
2793        // timer. The only thing that can follow the rotation is the `kid`
2794        // miss itself — the one request that proves the current binding is
2795        // stale. Without a re-read on that branch the rotated key is
2796        // unreachable for the whole interval, which is a hard verification
2797        // failure for every request in it.
2798        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2799
2800        let mut server = mockito::Server::new_async().await;
2801        let issuer = server.url();
2802        let audience = "https://api.example.com/mcp";
2803
2804        let rotated = Arc::new(AtomicBool::new(false));
2805        let metadata_hits = Arc::new(AtomicUsize::new(0));
2806        let v1_hits = Arc::new(AtomicUsize::new(0));
2807        let v2_hits = Arc::new(AtomicUsize::new(0));
2808
2809        let metadata_issuer = issuer.clone();
2810        let metadata_rotated = rotated.clone();
2811        let metadata_counter = metadata_hits.clone();
2812        let _metadata_mock = server
2813            .mock("GET", "/.well-known/oauth-authorization-server")
2814            .with_status(200)
2815            .with_header("content-type", "application/json")
2816            .with_body_from_request(move |_request| {
2817                metadata_counter.fetch_add(1, Ordering::SeqCst);
2818                let jwks_uri = if metadata_rotated.load(Ordering::SeqCst) {
2819                    format!("{metadata_issuer}/jwks-v2.json")
2820                } else {
2821                    format!("{metadata_issuer}/jwks-v1.json")
2822                };
2823                json!({ "issuer": metadata_issuer, "jwks_uri": jwks_uri })
2824                    .to_string()
2825                    .into_bytes()
2826            })
2827            .create_async()
2828            .await;
2829
2830        // The withdrawn document keeps serving the retired key, so a
2831        // verifier that never rebinds still gets a well-formed JWKS back and
2832        // fails only on the lookup. Nothing but the rebind can make the
2833        // rotated token verify.
2834        let v1_counter = v1_hits.clone();
2835        let _jwks_v1_mock = server
2836            .mock("GET", "/jwks-v1.json")
2837            .with_status(200)
2838            .with_header("content-type", "application/json")
2839            .with_body_from_request(move |_request| {
2840                v1_counter.fetch_add(1, Ordering::SeqCst);
2841                jwks_document_with_kid("jwks-v1-key")
2842                    .to_string()
2843                    .into_bytes()
2844            })
2845            .create_async()
2846            .await;
2847
2848        let v2_counter = v2_hits.clone();
2849        let _jwks_v2_mock = server
2850            .mock("GET", "/jwks-v2.json")
2851            .with_status(200)
2852            .with_header("content-type", "application/json")
2853            .with_body_from_request(move |_request| {
2854                v2_counter.fetch_add(1, Ordering::SeqCst);
2855                jwks_document_with_kid("jwks-v2-key")
2856                    .to_string()
2857                    .into_bytes()
2858            })
2859            .create_async()
2860            .await;
2861
2862        let client = crate::AuthplaneClient::builder(&issuer)
2863            .with_fetch_settings(FetchSettings::from_dev_mode(true))
2864            .with_metadata_refresh_seconds(3600)
2865            .with_jwks_refresh_seconds(3600)
2866            .build()
2867            .await
2868            .expect("client discovers the pre-rotation metadata");
2869        let resource = client
2870            .resource(audience, &["tools/read".to_string()])
2871            .await
2872            .expect("resource built through the public API");
2873
2874        resource
2875            .verify(&signed_token_with_kid(
2876                "jwks-v1-key",
2877                &issuer,
2878                audience,
2879                "jti-retired",
2880            ))
2881            .await
2882            .expect("token signed by the jwks-v1 key must verify before rotation");
2883        let metadata_hits_before = metadata_hits.load(Ordering::SeqCst);
2884
2885        // The AS rotates. No timer fires, no caller asks for a refresh.
2886        rotated.store(true, Ordering::SeqCst);
2887
2888        let claims = resource
2889            .verify(&signed_token_with_kid(
2890                "jwks-v2-key",
2891                &issuer,
2892                audience,
2893                "jti-rotated",
2894            ))
2895            .await
2896            .expect("the kid miss must re-read metadata and follow the rotation");
2897        assert_eq!(claims.kid, "jwks-v2-key");
2898        assert!(
2899            metadata_hits.load(Ordering::SeqCst) > metadata_hits_before,
2900            "the kid miss must re-read metadata, not wait for the interval",
2901        );
2902        assert_eq!(
2903            v2_hits.load(Ordering::SeqCst),
2904            1,
2905            "keys must be fetched from the rotated jwks_uri",
2906        );
2907        assert!(
2908            v1_hits.load(Ordering::SeqCst) >= 1,
2909            "the pre-rotation document must have been the one serving keys",
2910        );
2911
2912        client.aclose().await;
2913    }
2914
2915    #[tokio::test]
2916    async fn kid_miss_re_read_is_floored_so_an_unknown_kid_cannot_amplify_fetches() {
2917        // Counter-test to the one above. The re-read bypasses the refresh
2918        // interval by design, and the caller that reaches it has not
2919        // authenticated anything — `verify` has only decoded the header. A
2920        // well-formed header carrying an arbitrary `kid` must therefore not
2921        // cost the AS one discovery fetch per request.
2922        use std::sync::atomic::{AtomicUsize, Ordering};
2923
2924        let mut server = mockito::Server::new_async().await;
2925        let issuer = server.url();
2926        let audience = "https://api.example.com/mcp";
2927
2928        let metadata_hits = Arc::new(AtomicUsize::new(0));
2929        let metadata_issuer = issuer.clone();
2930        let metadata_counter = metadata_hits.clone();
2931        let _metadata_mock = server
2932            .mock("GET", "/.well-known/oauth-authorization-server")
2933            .with_status(200)
2934            .with_header("content-type", "application/json")
2935            .with_body_from_request(move |_request| {
2936                metadata_counter.fetch_add(1, Ordering::SeqCst);
2937                json!({
2938                    "issuer": metadata_issuer,
2939                    "jwks_uri": format!("{metadata_issuer}/jwks-v1.json")
2940                })
2941                .to_string()
2942                .into_bytes()
2943            })
2944            .create_async()
2945            .await;
2946
2947        let _jwks_mock = server
2948            .mock("GET", "/jwks-v1.json")
2949            .with_status(200)
2950            .with_header("content-type", "application/json")
2951            .with_body(jwks_document_with_kid("jwks-v1-key").to_string())
2952            .create_async()
2953            .await;
2954
2955        let client = crate::AuthplaneClient::builder(&issuer)
2956            .with_fetch_settings(FetchSettings::from_dev_mode(true))
2957            .with_metadata_refresh_seconds(3600)
2958            .with_jwks_refresh_seconds(3600)
2959            .build()
2960            .await
2961            .expect("client discovers metadata");
2962        let resource = client
2963            .resource(audience, &["tools/read".to_string()])
2964            .await
2965            .expect("resource built through the public API");
2966
2967        let boot_hits = metadata_hits.load(Ordering::SeqCst);
2968        for index in 0..8 {
2969            let token =
2970                signed_token_with_kid("unknown-kid", &issuer, audience, &format!("jti-{index}"));
2971            resource
2972                .verify(&token)
2973                .await
2974                .expect_err("an unknown kid must not verify");
2975        }
2976
2977        assert_eq!(
2978            metadata_hits.load(Ordering::SeqCst) - boot_hits,
2979            1,
2980            "the forced re-read floor must admit one discovery fetch, not one per request",
2981        );
2982
2983        client.aclose().await;
2984    }
2985
2986    #[tokio::test]
2987    async fn interval_re_read_follows_a_rotation_that_republishes_the_same_kid() {
2988        // The rotated document republishes the SAME `kid` with different key
2989        // material, so `get_key_by_kid` always finds the kid in whatever
2990        // document is bound and the miss branch can never fire. The only
2991        // mechanism that can make the rotated token verify is the
2992        // interval-driven re-read at the top of `lookup_key` — delete the
2993        // `refresh_if_due()` call and this test fails. It equally pins the
2994        // one job the rebind's cache expiry genuinely has: without it the
2995        // still-warm pre-rotation document keeps answering the shared kid
2996        // with the retired material for the whole JWKS TTL.
2997        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2998
2999        let mut server = mockito::Server::new_async().await;
3000        let issuer = server.url();
3001        let audience = "https://api.example.com/mcp";
3002        let shared_kid = "shared-kid";
3003
3004        let rotated = Arc::new(AtomicBool::new(false));
3005        let metadata_hits = Arc::new(AtomicUsize::new(0));
3006        let v1_hits = Arc::new(AtomicUsize::new(0));
3007        let v2_hits = Arc::new(AtomicUsize::new(0));
3008
3009        let metadata_issuer = issuer.clone();
3010        let metadata_rotated = rotated.clone();
3011        let metadata_counter = metadata_hits.clone();
3012        let _metadata_mock = server
3013            .mock("GET", "/.well-known/oauth-authorization-server")
3014            .with_status(200)
3015            .with_header("content-type", "application/json")
3016            .with_body_from_request(move |_request| {
3017                metadata_counter.fetch_add(1, Ordering::SeqCst);
3018                let jwks_uri = if metadata_rotated.load(Ordering::SeqCst) {
3019                    format!("{metadata_issuer}/jwks-v2.json")
3020                } else {
3021                    format!("{metadata_issuer}/jwks-v1.json")
3022                };
3023                json!({ "issuer": metadata_issuer, "jwks_uri": jwks_uri })
3024                    .to_string()
3025                    .into_bytes()
3026            })
3027            .create_async()
3028            .await;
3029
3030        let v1_counter = v1_hits.clone();
3031        let _jwks_v1_mock = server
3032            .mock("GET", "/jwks-v1.json")
3033            .with_status(200)
3034            .with_header("content-type", "application/json")
3035            .with_body_from_request(move |_request| {
3036                v1_counter.fetch_add(1, Ordering::SeqCst);
3037                jwks_document_with_kid("shared-kid")
3038                    .to_string()
3039                    .into_bytes()
3040            })
3041            .create_async()
3042            .await;
3043
3044        let v2_counter = v2_hits.clone();
3045        let _jwks_v2_mock = server
3046            .mock("GET", "/jwks-v2.json")
3047            .with_status(200)
3048            .with_header("content-type", "application/json")
3049            .with_body_from_request(move |_request| {
3050                v2_counter.fetch_add(1, Ordering::SeqCst);
3051                jwks_document_with_kid_and_second_key("shared-kid")
3052                    .to_string()
3053                    .into_bytes()
3054            })
3055            .create_async()
3056            .await;
3057
3058        // A one-second refresh interval so the gate comes due inside the
3059        // test; the JWKS TTL stays long so only the rebind's expiry — never
3060        // the document's own age — can force keys to be re-fetched.
3061        let client = crate::AuthplaneClient::builder(&issuer)
3062            .with_fetch_settings(FetchSettings::from_dev_mode(true))
3063            .with_metadata_refresh_seconds(1)
3064            .with_jwks_refresh_seconds(3600)
3065            .build()
3066            .await
3067            .expect("client discovers the pre-rotation metadata");
3068        let resource = client
3069            .resource(audience, &["tools/read".to_string()])
3070            .await
3071            .expect("resource built through the public API");
3072
3073        resource
3074            .verify(&signed_token_with_kid_and_key(
3075                shared_kid,
3076                TEST_PRIVATE_PEM,
3077                &issuer,
3078                audience,
3079                "jti-original",
3080            ))
3081            .await
3082            .expect("token signed by the original key must verify before rotation");
3083
3084        // The AS rotates: same kid, new material, new jwks_uri.
3085        rotated.store(true, Ordering::SeqCst);
3086        let v1_hits_at_rotation = v1_hits.load(Ordering::SeqCst);
3087
3088        // Let the refresh interval elapse so the next verification's gate
3089        // is unambiguously due — this test must go through the interval
3090        // path, not win a race against it.
3091        tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
3092
3093        let claims = resource
3094            .verify(&signed_token_with_kid_and_key(
3095                shared_kid,
3096                TEST_PRIVATE_PEM_2,
3097                &issuer,
3098                audience,
3099                "jti-rotated",
3100            ))
3101            .await
3102            .expect("the due re-read must rebind and fetch the same kid's new material");
3103        assert_eq!(claims.kid, shared_kid);
3104        assert!(
3105            v2_hits.load(Ordering::SeqCst) >= 1,
3106            "keys must have been re-fetched from the rotated jwks_uri",
3107        );
3108        assert_eq!(
3109            v1_hits.load(Ordering::SeqCst),
3110            v1_hits_at_rotation,
3111            "no further fetch of the withdrawn document once the rebind happened",
3112        );
3113
3114        client.aclose().await;
3115    }
3116}