Skip to main content

oauth_resource_server/
validator.rs

1//! [`OAuthValidator`]: JWT access-token validation against the authorization
2//! server's published keys, plus the metadata document and challenge headers
3//! derived from the same config.
4
5use std::sync::Arc;
6use std::time::Duration;
7
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use jsonwebtoken::{DecodingKey, Validation, decode, decode_header};
11use serde_json::{Map, Value};
12use tracing::{Instrument, Span, debug, error, info, warn};
13
14use crate::algorithms::Algorithm;
15use crate::builder::OAuthValidatorBuilder;
16use crate::challenge;
17use crate::config::ResolvedOAuthConfig;
18use crate::jwks::{
19    JWKS_BACKGROUND_REFRESH_INTERVAL, JwksStore, KeySetStatus, RefreshError,
20    background_retry_delay, http_clients, keyless_retry_delay, redact_url,
21};
22use crate::observe::record_field;
23use crate::token::{
24    AuthorizedToken, InvalidTokenKind, MAX_TOKEN_BYTES, TokenRejection, check_typ, client_id_of,
25    extract_principal, extract_scopes, for_log, for_log_field, missing_scopes, numeric_date_secs,
26};
27
28/// The span around one validation (`oauth_rs.validate`, or
29/// `oauth_rs.validate_cached` for the cache-only first pass): `debug`, target
30/// `oauth_resource_server::validator`. `kid`/`alg` come from the unverified
31/// header (bounded and escaped); `auth.outcome` is `accepted`, `rejected` or,
32/// for `validate_cached`, `needs_key_fetch`; `auth.reason` is the refusal's
33/// label. Field names are stable (the README's "Observability" section).
34macro_rules! validation_span {
35    ($name:literal) => {
36        tracing::debug_span!(
37            $name,
38            kid = tracing::field::Empty,
39            alg = tracing::field::Empty,
40            auth.outcome = tracing::field::Empty,
41            auth.reason = tracing::field::Empty,
42        )
43    };
44}
45
46/// Record a finished validation's outcome on its span: `refusal` is `None`
47/// for an accepted token.
48fn record_validation(span: &Span, refusal: Option<&TokenRejection>) {
49    match refusal {
50        None => {
51            record_field(span, "auth.outcome", "accepted");
52        }
53        Some(rejection) => {
54            record_field(span, "auth.outcome", "rejected");
55            record_field(span, "auth.reason", crate::observe::reason(rejection));
56        }
57    }
58}
59
60/// Why an [`OAuthValidator`] could not be built.
61#[derive(Debug, thiserror::Error)]
62#[non_exhaustive]
63pub enum ValidatorError {
64    /// Neither `audience` nor `audiences` holds a value. [`crate::OAuthConfig::resolve`]
65    /// refuses this; only a hand-edited [`ResolvedOAuthConfig`] reaches it.
66    #[error("{section}: no accepted audience configured")]
67    #[non_exhaustive]
68    NoAudience {
69        /// The config block, named per its [`crate::KeyNaming`].
70        section: String,
71    },
72    /// The algorithm allowlist is empty. Refused by `resolve` as well.
73    #[error("{key} is empty")]
74    #[non_exhaustive]
75    NoAlgorithms {
76        /// The `algorithms` setting, named per its [`crate::KeyNaming`].
77        key: String,
78    },
79    /// `leeway_secs` is over [`crate::MAX_LEEWAY_SECS`]. Refused by `resolve`
80    /// as well; re-checked because a larger leeway silently extends every
81    /// token's life (and past the current Unix time, overflows the expiry
82    /// arithmetic).
83    #[error("{key} {leeway_secs} is over the {max}-second cap")]
84    #[non_exhaustive]
85    LeewayTooLarge {
86        /// The `leeway_secs` setting, named per its [`crate::KeyNaming`].
87        key: String,
88        /// The configured value.
89        leeway_secs: u64,
90        /// [`crate::MAX_LEEWAY_SECS`].
91        max: u64,
92    },
93    /// The HTTP client for metadata/JWKS fetches could not be built (in
94    /// practice: the TLS backend failed to initialize).
95    ///
96    /// The underlying error is boxed rather than named, so the HTTP client
97    /// library's version is not part of this crate's public API; it is still
98    /// reachable through [`std::error::Error::source`].
99    #[error("Failed to build the HTTP client for OAuth metadata/JWKS fetches")]
100    HttpClient(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
101    /// [`OAuthValidatorBuilder::fetch_timeout`] is zero or outside
102    /// [`crate::MIN_FETCH_TIMEOUT`]`..=`[`crate::MAX_FETCH_TIMEOUT`].
103    #[error("fetch timeout {timeout:?} is outside the accepted {min:?}..={max:?}")]
104    #[non_exhaustive]
105    FetchTimeoutOutOfRange {
106        /// The requested timeout.
107        timeout: Duration,
108        /// [`crate::MIN_FETCH_TIMEOUT`].
109        min: Duration,
110        /// [`crate::MAX_FETCH_TIMEOUT`].
111        max: Duration,
112    },
113    /// A PEM passed to [`OAuthValidatorBuilder::add_root_certificate_pem`]
114    /// holds no certificate, or one the TLS backend cannot parse or use as a
115    /// trust anchor.
116    #[error("root certificate PEM #{index} is unusable: {reason}")]
117    #[non_exhaustive]
118    InvalidRootCertificate {
119        /// Which call it came from: 0 for the first
120        /// `add_root_certificate_pem`, 1 for the second, and so on.
121        index: usize,
122        /// Why, for a log line. Certificates are public, so this may quote
123        /// the TLS library's error.
124        reason: String,
125    },
126    /// The URL passed to [`OAuthValidatorBuilder::proxy`] is refused:
127    /// malformed, or plain http on a non-loopback host with a credential in
128    /// it and no `allow_insecure_http`.
129    ///
130    /// # Security
131    ///
132    /// A refused value is never echoed, not even redacted — a malformed URL
133    /// can hide a credential where no parser sees userinfo. `proxy` is only
134    /// the scheme (`http://<redacted>`) when it is a proxy scheme, and
135    /// `<redacted>` otherwise; `reason` never quotes the URL. This error is
136    /// safe to log whatever the proxy URL carries.
137    #[error("proxy {proxy} is refused: {reason}")]
138    #[non_exhaustive]
139    InvalidProxy {
140        /// `<scheme>://<redacted>`, or `<redacted>`: never the URL itself.
141        proxy: String,
142        /// Why.
143        reason: String,
144    },
145    /// The JWK Set passed to [`OAuthValidatorBuilder::initial_jwks`] is too
146    /// large, not JSON, not a JWK Set, or holds no key usable for a signature
147    /// under the configured algorithms.
148    #[error("initial JWKS is unusable: {reason}")]
149    #[non_exhaustive]
150    InvalidInitialJwks {
151        /// Why. Public keys only, so safe to log.
152        reason: String,
153    },
154}
155
156/// The outcome of a cache-only validation attempt (`OAuthValidator::validate_cached`).
157// One value per request, moved straight out: boxing the token buys nothing.
158#[allow(clippy::large_enum_variant)]
159pub(crate) enum CachedAttempt {
160    /// Decided without any key fetch: accepted, or refused for a reason a
161    /// fetch could not change.
162    Decided(Result<AuthorizedToken, TokenRejection>),
163    /// The header checks passed but no key already held fits `kid`/`alg`;
164    /// only a full `validate` (which may refetch the JWKS) can decide it.
165    NeedsKeyFetch,
166}
167
168/// Validates bearer credentials as JWT access tokens (RFC 9068) for one resource.
169///
170/// Built once from a [`ResolvedOAuthConfig`] and shared (`Arc`) for the life of
171/// the process; nothing about it hot-reloads. It never issues, refreshes, revokes
172/// or introspects tokens, and it only talks to the authorization server to fetch
173/// its metadata (when no `jwks_uri` is configured) and its public signing keys.
174///
175/// Every check that can be made from the unverified header (size, JWS shape,
176/// `crit`, `alg` allowlist, `typ`) runs before any key is fetched, so junk
177/// cannot schedule IdP traffic. Signature, `iss`, `aud`, `exp` and `nbf` are all
178/// checked inside one `jsonwebtoken::decode`, so the claim checks can never be
179/// reordered ahead of the signature. Every failure fails closed.
180///
181/// # Runtime
182///
183/// Validation, [`OAuthValidator::refresh_now`] and
184/// [`OAuthValidator::spawn_background_refresh`] need a Tokio 1.x runtime: key
185/// fetches use `reqwest` (with a Tokio timer) and run in a spawned task. Called
186/// outside one, the first key fetch panics. On `async-std`, `smol` or another
187/// executor, drive them from a Tokio runtime handle.
188///
189/// # Extension point: opaque tokens
190///
191/// Opaque (non-JWT) access tokens are refused today. RFC 7662 introspection would
192/// cover them but needs a client credential and per-request AS round trips, so it
193/// is deliberately not built. An introspection backend would be a feature-gated
194/// alternative to the JWKS key source, chosen at construction, and
195/// [`OAuthValidator::validate`] would dispatch to it where it now refuses a
196/// non-JWT credential. Its result is the same [`AuthorizedToken`] (and
197/// [`TokenRejection`]), both `#[non_exhaustive]`, so code consuming a validation
198/// result is unaffected; [`ResolvedOAuthConfig`] is `#[non_exhaustive]` too, so a
199/// new resolved setting is an additive change.
200///
201/// [`crate::OAuthConfig`] is deliberately NOT `#[non_exhaustive]`: applications
202/// build it with a functional-record update (`OAuthConfig { enabled: true,
203/// ..OAuthConfig::default() }`), which that attribute would forbid outside
204/// this crate, and which keeps compiling even after a field is added. Adding
205/// introspection keys to it (endpoint, client credential) would instead break
206/// only an exhaustive struct literal or destructuring pattern that names
207/// every field — possible today because every field is public — and would
208/// still ship in a new `0.x` minor release, which Cargo already treats as
209/// incompatible — unless those settings are passed to a separate constructor
210/// instead, which leaves `OAuthConfig` untouched.
211pub struct OAuthValidator {
212    /// Everything below is derived from this once; it is kept for scope and
213    /// claim policy and for logging.
214    config: ResolvedOAuthConfig,
215    /// Pre-rendered so the 401/403 paths are a string clone, not a `format!` per
216    /// rejected request.
217    resource_metadata_url: String,
218    /// The route the metadata document must be served on (the path of
219    /// `resource_metadata_url`).
220    metadata_path: String,
221    /// `required_scopes`, space-joined, for the 403 challenge and the log line.
222    required_scopes: String,
223    /// The two `WWW-Authenticate` values, rendered once. Always valid header
224    /// values: when the configured ones would not be (a control or non-ASCII
225    /// character in a hand-edited `resource` or scope), `build` logs at
226    /// `error` and stores `challenge::fallback`s instead.
227    invalid_token_challenge: String,
228    insufficient_scope_challenge: String,
229    /// Whether `build` fell back. The layers refuse to build with such a
230    /// validator (`AuthLayerError::InvalidChallenge`), as they always have.
231    #[cfg_attr(not(any(feature = "tower", test)), allow(dead_code))] // read by the layers only
232    challenge_fallback: bool,
233    /// The RFC 9728 document, rendered once.
234    metadata: Value,
235    /// Allowlisted algorithms in the JWT library's form, for the header check.
236    jwt_algorithms: Vec<jsonwebtoken::Algorithm>,
237    /// Issuer/audience/expiry/not-before policy, built once. Each token gets a
238    /// clone with `algorithms` narrowed to its own (already allowlisted and
239    /// key-compatible) `alg`, because `jsonwebtoken` refuses a `Validation` whose
240    /// algorithms span more than one key family. The claim checks all run inside
241    /// `decode`, which is what keeps signature verification and claim validation
242    /// from being two separately-forgettable steps.
243    validation: Validation,
244    /// The signing keys: cache, discovery, refresh and rate limiting. Shared
245    /// with the detached tasks that run its fetches.
246    keys: Arc<JwksStore>,
247    /// Dropped with the validator, which is how the background refresh task
248    /// learns to stop: it waits on a receiver whose `changed()` resolves once
249    /// this sender is gone.
250    alive: tokio::sync::watch::Sender<()>,
251}
252
253/// Hand-written: prints the issuer and resource through `redact_url`, so a
254/// credential in either never reaches a log line through `{:?}`.
255impl std::fmt::Debug for OAuthValidator {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        f.debug_struct("OAuthValidator")
258            .field("issuer", &redact_url(&self.config.issuer))
259            .field("resource", &redact_url(&self.config.resource))
260            .field("required_scopes", &self.config.required_scopes)
261            .finish_non_exhaustive()
262    }
263}
264
265impl OAuthValidator {
266    /// Build a validator. Does no I/O: keys are fetched on first use, or earlier
267    /// by [`OAuthValidator::spawn_background_refresh`] /
268    /// [`OAuthValidator::refresh_now`].
269    ///
270    /// Build one per process and share it (`Arc`): it owns the key cache, so
271    /// separate validators would each fetch and refresh their own keys.
272    ///
273    /// Logs a `warn` for a configuration that works but is weaker than it
274    /// probably should be: a required scope missing from `scopes_supported`
275    /// (clients that request the advertised scopes will get 403), no required
276    /// scope with `require_at_jwt` off (ID tokens for the same client are
277    /// accepted), and a plain-`http` issuer, `jwks_uri` or resource on a
278    /// non-loopback host. [`crate::OAuthConfig::resolve`] refuses the last two
279    /// unless the config opts in explicitly; the warning is for the deployments
280    /// that did.
281    ///
282    /// # Errors
283    ///
284    /// [`ValidatorError`] when the config has no accepted audience, no
285    /// algorithm, or a `leeway_secs` over [`crate::MAX_LEEWAY_SECS`] (none of
286    /// which a config from [`crate::OAuthConfig::resolve`] can have, but the
287    /// fields of [`ResolvedOAuthConfig`] are public), or when the HTTP client
288    /// for key fetches cannot be built (the TLS backend failed to initialize).
289    ///
290    /// # Examples
291    ///
292    /// ```
293    /// use std::sync::Arc;
294    ///
295    /// use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};
296    ///
297    /// let resolved = OAuthConfig {
298    ///     enabled: true,
299    ///     issuer: "https://auth.example.com/".into(),
300    ///     audience: "example-api".into(),
301    ///     resource: "https://api.example.com/v1".into(),
302    ///     required_scope: Some("api:read".into()),
303    ///     scopes_supported: Some(vec!["api:read".into()]),
304    ///     ..OAuthConfig::default()
305    /// }
306    /// .resolve(KeyNaming::Dotted("oauth"))
307    /// .unwrap()
308    /// .unwrap();
309    ///
310    /// let validator = Arc::new(OAuthValidator::new(&resolved).unwrap());
311    /// assert_eq!(
312    ///     validator.metadata_path(),
313    ///     "/.well-known/oauth-protected-resource/v1"
314    /// );
315    /// assert_eq!(
316    ///     validator.insufficient_scope_challenge(),
317    ///     "Bearer error=\"insufficient_scope\", scope=\"api:read\", \
318    ///      resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/v1\""
319    /// );
320    /// // In a server, inside the tokio runtime:
321    /// // validator.spawn_background_refresh();
322    /// ```
323    pub fn new(config: &ResolvedOAuthConfig) -> Result<Self, ValidatorError> {
324        Self::builder(config).build()
325    }
326
327    /// A builder for a validator whose key fetches need more than
328    /// [`OAuthValidator::new`] gives them: an extra trust anchor for an
329    /// authorization server behind a private CA, an explicit proxy, a
330    /// different fetch timeout, or a key set to start from. With no option
331    /// set, [`OAuthValidatorBuilder::build`] is exactly
332    /// [`OAuthValidator::new`].
333    ///
334    /// These are code, not [`crate::OAuthConfig`] settings: like the config,
335    /// they take effect only when a validator is built (a restart).
336    ///
337    /// # Examples
338    ///
339    /// ```
340    /// use std::time::Duration;
341    ///
342    /// use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};
343    ///
344    /// # let resolved = OAuthConfig {
345    /// #     enabled: true,
346    /// #     issuer: "https://auth.example.com/".into(),
347    /// #     audience: "example-api".into(),
348    /// #     resource: "https://api.example.com/".into(),
349    /// #     required_scope: Some("api:read".into()),
350    /// #     ..OAuthConfig::default()
351    /// # }
352    /// # .resolve(KeyNaming::Dotted("oauth"))
353    /// # .unwrap()
354    /// # .unwrap();
355    /// let validator = OAuthValidator::builder(&resolved)
356    ///     .fetch_timeout(Duration::from_secs(5))
357    ///     .proxy("http://proxy.example.com:3128")
358    ///     .build()
359    ///     .unwrap();
360    /// # drop(validator);
361    /// ```
362    pub fn builder(config: &ResolvedOAuthConfig) -> OAuthValidatorBuilder {
363        OAuthValidatorBuilder::new(config)
364    }
365
366    /// [`OAuthValidator::new`] with the unknown-`kid` refetch interval
367    /// overridden — for tests, which would otherwise sleep a minute.
368    #[cfg(test)]
369    pub(crate) fn build(
370        config: &ResolvedOAuthConfig,
371        jwks_min_refetch_interval: Duration,
372    ) -> Result<Self, ValidatorError> {
373        Self::builder(config)
374            .min_refetch_interval(jwks_min_refetch_interval)
375            .build()
376    }
377
378    /// The body of [`OAuthValidatorBuilder::build`]: every check
379    /// [`OAuthValidator::new`] has always made, in the same order, then the
380    /// builder's own options.
381    pub(crate) fn from_builder(builder: &OAuthValidatorBuilder) -> Result<Self, ValidatorError> {
382        let config = builder.config();
383        let naming = &config.key_naming;
384        // `OAuthConfig::resolve` already refuses all three of these; re-checked
385        // here because `ResolvedOAuthConfig`'s fields are public and may be
386        // adjusted after resolving. An empty audience set or algorithm list is
387        // the construction mistake that would fail OPEN-adjacent (an empty `aud`
388        // set in jsonwebtoken means "reject everything", but an empty allowlist
389        // is a panic-free foot-gun nobody should have to reason about), and an
390        // oversized leeway silently extends every token's life — jsonwebtoken
391        // computes `now - leeway` unchecked, so past `now` it also overflows.
392        let audiences = config.accepted_audiences();
393        if audiences.is_empty() {
394            return Err(ValidatorError::NoAudience {
395                section: naming.section(),
396            });
397        }
398        let Some(&first_alg) = config.algorithms.first() else {
399            return Err(ValidatorError::NoAlgorithms {
400                key: naming.key("algorithms"),
401            });
402        };
403        if config.leeway_secs > crate::config::MAX_LEEWAY_SECS {
404            return Err(ValidatorError::LeewayTooLarge {
405                key: naming.key("leeway_secs"),
406                leeway_secs: config.leeway_secs,
407                max: crate::config::MAX_LEEWAY_SECS,
408            });
409        }
410
411        let mut validation = Validation::new(first_alg.to_jwt());
412        // Byte-exact issuer match. Authentik's issuer ends in a slash and the
413        // difference matters — `.../example-app/` and `.../example-app` are
414        // different strings and only one of them is in the tokens.
415        validation.set_issuer(&[&config.issuer]);
416        // Membership, per RFC 7519 §4.1.3: `aud` may be a string or an array, and
417        // the token is accepted if ANY element is one of the configured audiences.
418        // What those audiences should be is provider-specific and deliberately
419        // config, never guessed — the client_id on servers that ignore RFC 8707
420        // (Authentik, Kanidm), the resource URL on servers configured to stamp it
421        // (Authelia with a client `audience`). See `OAuthConfig::audience`.
422        validation.set_audience(&audiences);
423        // `jsonwebtoken` only validates `iss`/`aud` when the claim is *present*, so
424        // requiring them here is what turns "wrong issuer" and "no issuer at all"
425        // into the same refusal. Without this a token carrying neither claim would
426        // sail through both checks.
427        validation.set_required_spec_claims(&["exp", "iss", "aud"]);
428        validation.leeway = config.leeway_secs;
429        validation.validate_exp = true;
430        // Off by default in jsonwebtoken. RFC 9068 tokens (Authelia, Kanidm) carry
431        // `nbf`; a token presented before it is not yet valid. jsonwebtoken skips
432        // an `nbf` it cannot read as a number, so `verify` refuses one of those
433        // itself (`nbf_is_numeric_date`).
434        validation.validate_nbf = true;
435        validation.validate_aud = true;
436
437        let resource_metadata_url = challenge::resource_metadata_url(&config.resource);
438        let metadata_path = challenge::metadata_path(&resource_metadata_url);
439        let required_scopes = config.required_scopes.join(" ");
440        // The 401's `scope` names what to ask for: the advertised menu, or —
441        // when the config advertises none — what is required, so a client is
442        // never left to request nothing and be refused with 403.
443        let supported_scopes = if config.scopes_supported.is_empty() {
444            required_scopes.clone()
445        } else {
446            config.scopes_supported.join(" ")
447        };
448        // Every challenge this validator (and so `refusal()`) hands out must be
449        // a valid header value. `resolve` refuses every config that would break
450        // one; a hand-edited resolved config still builds (refusing it here
451        // would narrow what builds), but gets a safe fallback and a loud log
452        // line, and the layers refuse it (`challenge_fallback`).
453        let mut invalid_token_challenge =
454            challenge::invalid_token(&resource_metadata_url, &supported_scopes);
455        let mut insufficient_scope_challenge =
456            challenge::insufficient_scope(&required_scopes, &resource_metadata_url);
457        let challenge_fallback = !challenge::is_header_value(&invalid_token_challenge)
458            || !challenge::is_header_value(&insufficient_scope_challenge);
459        if challenge_fallback {
460            invalid_token_challenge = challenge::fallback("invalid_token", &supported_scopes);
461            insufficient_scope_challenge =
462                challenge::fallback("insufficient_scope", &required_scopes);
463            error!(
464                resource = %redact_url(&config.resource),
465                "the WWW-Authenticate challenge built from {}, {} and {} is not a valid HTTP \
466                 header value (a control or non-ASCII character); sending a fallback \
467                 challenge without resource_metadata, which clients need to find the \
468                 authorization server. Fix the configuration (resolve refuses it).",
469                naming.key("resource"),
470                naming.key("scopes_supported"),
471                naming.key("required_scopes"),
472            );
473        }
474
475        // A required scope nobody is told to ask for is a guaranteed 403 for every
476        // client that requests exactly `scopes_supported`. Not fatal — an operator
477        // may be advertising a narrower menu on purpose — but never silent. An
478        // empty `scopes_supported` is not that case: the challenge then names
479        // the required scopes itself (`supported_scopes` above), so a client is
480        // told exactly what to request.
481        let unadvertised = unadvertised_scopes(config);
482        if !unadvertised.is_empty() {
483            // Names the scopes rather than a setting: `required_scopes` is the union
484            // of `required_scope` and `required_scopes`, and which of the two an
485            // unadvertised scope came from is not known here.
486            warn!(
487                unadvertised_scopes = %unadvertised.join(" "),
488                scopes_supported = ?config.scopes_supported,
489                "required scope(s) {} not in {} — clients that request the advertised \
490                 scopes will get 403 insufficient_scope",
491                unadvertised.join(" "),
492                naming.key("scopes_supported")
493            );
494        }
495        // Valid by design (an application may need only "signed by this issuer for
496        // this audience"), but a weaker posture than a scoped deployment, so it is
497        // said once at construction rather than left implicit. With `typ` not
498        // enforced either, nothing tells an access token from an ID token minted
499        // for the same client: on servers that stamp the client_id as `aud`
500        // (Authentik, Kanidm) the ID token a front end got from an OIDC login is
501        // then a working bearer credential. That combination is a warning.
502        match unscoped_posture(config) {
503            UnscopedPosture::Scoped => {}
504            UnscopedPosture::UnscopedButTypEnforced => info!(
505                "no required scope configured ({} and {} unset) — every valid access \
506                 token (typ at+jwt) for the audience is accepted",
507                naming.key("required_scope"),
508                naming.key("required_scopes")
509            ),
510            UnscopedPosture::IdTokensAccepted => warn!(
511                "no required scope configured ({} and {} unset) and {} is off — ANY token \
512                 this issuer signs for the audience is accepted, including an OIDC ID token \
513                 minted for the same client. Set {} (a scope only access tokens carry) or \
514                 turn on {} if the authorization server emits typ at+jwt.",
515                naming.key("required_scope"),
516                naming.key("required_scopes"),
517                naming.key("require_at_jwt"),
518                naming.key("required_scope"),
519                naming.key("require_at_jwt")
520            ),
521        }
522        if plain_http_non_loopback(&config.issuer) {
523            warn!(
524                issuer = %redact_url(&config.issuer),
525                "{} uses plain http on a non-loopback host — signing keys fetched over it \
526                 can be substituted by anyone on the path. Use https.",
527                naming.key("issuer")
528            );
529        }
530        if plain_http_non_loopback(&config.resource) {
531            warn!(
532                resource = %redact_url(&config.resource),
533                "{} uses plain http on a non-loopback host — bearer tokens sent to it can \
534                 be read in transit. Use https.",
535                naming.key("resource")
536            );
537        }
538        if let Some(jwks_uri) = config.jwks_uri.as_deref().map(str::trim)
539            && plain_http_non_loopback(jwks_uri)
540        {
541            // The discovered-URI path refuses this outright when the issuer is
542            // https, and without `allow_insecure_http` otherwise
543            // (`jwks_uri_from_metadata`; `refresh` warns when the opt-in lets
544            // one through). A configured one reaches here only
545            // with `allow_insecure_http` (or a hand-edited resolved config) — an
546            // in-cluster `http://idp:9000/...` behind a private network is a real
547            // deployment shape — so it is warned about, never silent.
548            warn!(
549                jwks_uri = %redact_url(jwks_uri),
550                "{} uses plain http on a non-loopback host — signing keys fetched over it \
551                 can be substituted by anyone on the path. Use https.",
552                naming.key("jwks_uri")
553            );
554        }
555        for message in non_canonical_warnings(config) {
556            warn!("{message}");
557        }
558
559        for (claim, why) in required_claim_footguns(config) {
560            warn!(
561                claim = %for_log(claim),
562                "{} names {:?}: {} — it still works, but probably not as intended",
563                naming.key("required_claims"),
564                for_log(claim),
565                why
566            );
567        }
568
569        let metadata = challenge::metadata_document(config);
570        let settings = builder.fetch_settings()?;
571        let seed = builder.seed_keys()?;
572        let http = http_clients(
573            config.allow_insecure_http,
574            &naming.key("allow_insecure_http"),
575            &settings,
576        )
577        .map_err(|e| ValidatorError::HttpClient(Box::new(e)))?;
578
579        Ok(Self {
580            config: config.clone(),
581            resource_metadata_url,
582            metadata_path,
583            required_scopes,
584            invalid_token_challenge,
585            insufficient_scope_challenge,
586            challenge_fallback,
587            metadata,
588            jwt_algorithms: config.algorithms.iter().map(|a| a.to_jwt()).collect(),
589            validation,
590            keys: Arc::new(JwksStore::new(
591                config,
592                http,
593                builder.refetch_interval(),
594                seed,
595            )),
596            alive: tokio::sync::watch::channel(()).0,
597        })
598    }
599
600    /// The config this validator was built from.
601    pub fn config(&self) -> &ResolvedOAuthConfig {
602        &self.config
603    }
604
605    /// The protected resource's identifier ([`crate::OAuthConfig::resource`]).
606    pub fn resource(&self) -> &str {
607        &self.config.resource
608    }
609
610    /// The protected-resource metadata URL advertised in every challenge's
611    /// `resource_metadata` parameter (RFC 9728 §3: the well-known segment spliced
612    /// between the resource's authority and path).
613    pub fn resource_metadata_url(&self) -> &str {
614        &self.resource_metadata_url
615    }
616
617    /// The path of [`OAuthValidator::resource_metadata_url`] — the route the
618    /// metadata document must be served on, e.g.
619    /// `/.well-known/oauth-protected-resource/mcp` for a resource at `/mcp`, or
620    /// the bare [`crate::PROTECTED_RESOURCE_METADATA_PREFIX`] for a resource at
621    /// the root.
622    ///
623    /// It comes from config and may contain characters a router reads as
624    /// pattern syntax (`{…}`, or a segment starting with `:` or `*`, which axum
625    /// refuses with a panic), so an app serving it itself should compare the
626    /// request path against it literally rather than register it as a route.
627    /// The `axum` feature's `metadata_router` does exactly that.
628    pub fn metadata_path(&self) -> &str {
629        &self.metadata_path
630    }
631
632    /// The RFC 9728 protected-resource metadata document, rendered once at
633    /// construction. `scopes_supported` is left out when the list is empty
634    /// (RFC 9728 §3.2: a parameter with zero values is omitted).
635    pub fn metadata(&self) -> &Value {
636        &self.metadata
637    }
638
639    /// The `WWW-Authenticate` value for every 401 — a refused credential and, by
640    /// deliberate choice, a missing one too:
641    /// `Bearer error="invalid_token", resource_metadata="…", scope="…"`.
642    /// `scope` lists `scopes_supported`, or the required scopes when nothing is
643    /// advertised (the MCP authorization spec asks servers to name the scopes
644    /// needed here). With neither, the parameter is omitted, not sent empty:
645    /// RFC 6749 §3.3 requires at least one scope-token.
646    ///
647    /// Load-bearing, not cosmetic: claude.ai has been observed refusing to start
648    /// the authorization flow at all when a 401 arrives without it, because
649    /// `resource_metadata` is how the client finds the authorization server in the
650    /// first place. Claude Code tolerates its absence, which is exactly why it is
651    /// easy to drop and hard to notice. Emit it on EVERY 401 once OAuth is
652    /// configured — including a failed static-token request, since the server
653    /// cannot tell which credential the caller meant to present.
654    ///
655    /// Always a valid header value: with a hand-edited config that would break
656    /// it (a control or non-ASCII character in `resource` or a scope), this is
657    /// the fallback `Bearer error="invalid_token"` (plus `scope` when that is
658    /// valid), logged at `error` when the validator is built.
659    pub fn invalid_token_challenge(&self) -> String {
660        self.invalid_token_challenge.clone()
661    }
662
663    /// The `WWW-Authenticate` value for a 403:
664    /// `Bearer error="insufficient_scope", scope="…", resource_metadata="…"`.
665    ///
666    /// The token was genuinely valid, so `scope` names what is *required* —
667    /// every required scope, space-delimited (RFC 6750 §3) — rather than
668    /// everything on offer. That is the difference that lets a client
669    /// re-authorize for the right thing instead of replaying the same request.
670    /// With no required scope (no token is ever refused for scope) the `scope`
671    /// parameter is omitted.
672    ///
673    /// Always a valid header value, falling back as
674    /// [`OAuthValidator::invalid_token_challenge`] does.
675    pub fn insufficient_scope_challenge(&self) -> String {
676        self.insufficient_scope_challenge.clone()
677    }
678
679    /// A 403 challenge for ONE request, naming the scopes that request needs
680    /// rather than the validator's fixed set:
681    /// `Bearer error="insufficient_scope", scope="…", resource_metadata="…",
682    /// error_description="…"` (the shape the MCP authorization spec asks for
683    /// on a per-operation refusal). [`insufficient_scope_challenge`](Self::insufficient_scope_challenge)
684    /// is exactly this with the configured `required_scopes` and no
685    /// description.
686    ///
687    /// `scopes` is named verbatim, deduplicated, in order — pass every scope
688    /// the request needs, the validator's own required scopes included, so a
689    /// client that re-authorizes for exactly this set gets a token that
690    /// passes ([`crate::refusal_for_scopes`] and the layers add them for
691    /// you). With no scope to name, `scope` is omitted.
692    ///
693    /// # Security
694    ///
695    /// Always a valid header value, whatever the arguments:
696    ///
697    /// - a `scopes` entry that is not an RFC 6749 §3.3 scope-token (empty,
698    ///   or holding a space, `"`, `\`, a control or non-ASCII character) is
699    ///   left out of `scope` — no token can carry such a scope anyway;
700    /// - `description` is reduced to RFC 6750 §3's `error_description`
701    ///   character set: every `"`, `\`, control (CR and LF included) and
702    ///   non-ASCII character becomes a space, so it can neither close the
703    ///   quoted string nor split the header. It is then trimmed, cut to 256
704    ///   bytes, and left out when blank. It is still sent to the client:
705    ///   never put a token-derived or secret value in it;
706    /// - a validator built from a hand-edited config whose challenges fell
707    ///   back (see [`invalid_token_challenge`](Self::invalid_token_challenge))
708    ///   leaves `resource_metadata` out here too.
709    ///
710    /// # Examples
711    ///
712    /// ```
713    /// # use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};
714    /// # let config = OAuthConfig {
715    /// #     enabled: true,
716    /// #     issuer: "https://auth.example.com/".into(),
717    /// #     audience: "example-api".into(),
718    /// #     resource: "https://api.example.com/v1".into(),
719    /// #     required_scope: Some("api:read".into()),
720    /// #     ..OAuthConfig::default()
721    /// # }
722    /// # .resolve(KeyNaming::Dotted("oauth"))
723    /// # .unwrap()
724    /// # .unwrap();
725    /// # let validator = OAuthValidator::new(&config).unwrap();
726    /// assert_eq!(
727    ///     validator.insufficient_scope_challenge_for(
728    ///         &["api:read", "api:write"],
729    ///         Some("writing needs api:write"),
730    ///     ),
731    ///     "Bearer error=\"insufficient_scope\", scope=\"api:read api:write\", \
732    ///      resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/v1\", \
733    ///      error_description=\"writing needs api:write\""
734    /// );
735    ///
736    /// // A description cannot inject an attribute or split the header.
737    /// let challenge = validator
738    ///     .insufficient_scope_challenge_for(&["api:write"], Some("x\", scope=\"admin\r\nX: y"));
739    /// assert!(challenge.ends_with("error_description=\"x , scope= admin  X: y\""));
740    /// ```
741    pub fn insufficient_scope_challenge_for(
742        &self,
743        scopes: &[&str],
744        description: Option<&str>,
745    ) -> String {
746        let url = (!self.challenge_fallback).then_some(self.resource_metadata_url.as_str());
747        let challenge = challenge::insufficient_scope_for(scopes, url, description);
748        // Unreachable (every part is filtered above), but the guarantee is
749        // "always a header value", so it is checked, not assumed.
750        if challenge::is_header_value(&challenge) {
751            challenge
752        } else {
753            challenge::fallback("insufficient_scope", "")
754        }
755    }
756
757    /// The validator's own `required_scopes` followed by `extra`,
758    /// deduplicated: every scope a request needs that must pass both the
759    /// validator's floor and a per-route or per-operation requirement.
760    pub(crate) fn scopes_with_floor<'a>(&'a self, extra: &[&'a str]) -> Vec<&'a str> {
761        let mut all: Vec<&str> = Vec::new();
762        for scope in self
763            .config
764            .required_scopes
765            .iter()
766            .map(String::as_str)
767            .chain(extra.iter().copied())
768        {
769            if !all.contains(&scope) {
770                all.push(scope);
771            }
772        }
773        all
774    }
775
776    /// Whether the configured challenges were not valid header values and the
777    /// fallbacks are in use; the layers refuse to build with such a validator.
778    #[cfg_attr(not(any(feature = "tower", test)), allow(dead_code))] // read by the layers only
779    pub(crate) fn challenge_fell_back(&self) -> bool {
780        self.challenge_fallback
781    }
782
783    /// Validate a bearer credential as a JWT access token.
784    ///
785    /// Order matters and is RFC 9068 §4's: everything that can be refused from the
786    /// unverified header alone (size, shape, `alg` allowlist, `typ`) is refused
787    /// before any key is fetched, so junk cannot schedule IdP traffic; then the
788    /// signature; then issuer / audience / expiry / not-before — all inside
789    /// `jsonwebtoken::decode`, so they cannot be reordered ahead of the signature by
790    /// accident — then scope: the token must carry EVERY required scope.
791    ///
792    /// `token` is the credential alone, without the `Bearer ` prefix. When the
793    /// signing key is not cached this fetches the JWKS (at most once a minute
794    /// for an unknown `kid`), so the call can wait on a fetch, each bounded by
795    /// a 10-second timeout. The fetch runs in a task of its own, so dropping
796    /// this future does not cancel it. Logs an insufficient scope at `info` and
797    /// a failed key refresh at `warn`; logging the outcome is the caller's job.
798    /// Runs in a `debug` span `oauth_rs.validate` recording the unverified
799    /// header's `kid` and `alg` (cut to 128 characters, anything outside
800    /// printable ASCII escaped) and the outcome (`auth.outcome`,
801    /// `auth.reason`); a key fetch it triggers runs in an `info` span
802    /// `oauth_rs.jwks_refresh` below it. See the README's "Observability"
803    /// section.
804    ///
805    /// # Errors
806    ///
807    /// - [`TokenRejection::Missing`] for an empty `token`.
808    /// - [`TokenRejection::Invalid`] for everything that makes the token no
809    ///   good: over 16 KiB, not a JWT, an unparsable header, a header listing
810    ///   critical extensions (`crit`, RFC 7515 §4.1.11: this crate supports
811    ///   none), an `alg` outside the allowlist, a refused `typ`, no usable key,
812    ///   a bad signature, a wrong or missing `iss`/`aud`, an expired or
813    ///   not-yet-valid token, an `nbf` that is not a NumericDate, or a
814    ///   sender-constrained token (a `cnf` claim: DPoP, RFC 9449 §7.2, or
815    ///   mTLS, RFC 8705 §3), which this crate cannot verify the binding of and
816    ///   so will not accept as a plain bearer token; and, only when configured,
817    ///   a client not in `allowed_client_ids`, a token older than
818    ///   `max_token_age_secs` (or without a readable `iat`), or a
819    ///   `required_claims` entry missing or not matched. Its
820    ///   [`InvalidToken::kind`](crate::InvalidToken::kind) says which.
821    /// - [`TokenRejection::InsufficientScope`] for a valid token that lacks a
822    ///   required scope.
823    ///
824    /// # Panics
825    ///
826    /// Outside a Tokio 1.x runtime, when a key has to be fetched (see
827    /// [Runtime](OAuthValidator#runtime)).
828    ///
829    /// # Security
830    ///
831    /// The reason inside `Invalid` names the check that failed. Log it; never
832    /// send it to the caller, for whom it would be an oracle. Answer with
833    /// [`OAuthValidator::invalid_token_challenge`] or
834    /// [`OAuthValidator::insufficient_scope_challenge`] instead.
835    ///
836    /// # Examples
837    ///
838    /// ```no_run
839    /// use oauth_resource_server::{OAuthValidator, TokenRejection};
840    ///
841    /// /// The status and `WWW-Authenticate` value for a request.
842    /// async fn check(validator: &OAuthValidator, bearer: &str) -> (u16, Option<String>) {
843    ///     match validator.validate(bearer).await {
844    ///         Ok(token) => {
845    ///             println!("accepted {:?} with scopes {:?}", token.subject, token.scopes);
846    ///             (200, None)
847    ///         }
848    ///         Err(TokenRejection::InsufficientScope) => {
849    ///             (403, Some(validator.insufficient_scope_challenge()))
850    ///         }
851    ///         Err(rejection) => {
852    ///             eprintln!("refused: {rejection:?}"); // for the log only
853    ///             (401, Some(validator.invalid_token_challenge()))
854    ///         }
855    ///     }
856    /// }
857    /// ```
858    pub async fn validate(&self, token: &str) -> Result<AuthorizedToken, TokenRejection> {
859        // A span of its own (see `validation_span`), entered only when a
860        // subscriber wants it: disabled, it is `Span::none()` and the future
861        // runs unwrapped, exactly as without tracing.
862        let span = validation_span!("oauth_rs.validate");
863        if span.is_disabled() {
864            return self.validate_in(token, &span).await;
865        }
866        let result = self
867            .validate_in(token, &span)
868            .instrument(span.clone())
869            .await;
870        record_validation(&span, result.as_ref().err());
871        result
872    }
873
874    /// The body of [`OAuthValidator::validate`], recording the unverified
875    /// header's `kid`/`alg` on `span`.
876    async fn validate_in(
877        &self,
878        token: &str,
879        span: &Span,
880    ) -> Result<AuthorizedToken, TokenRejection> {
881        let header = self.check_header(token, span)?;
882        let key = self
883            .keys
884            .decoding_key(header.kid.as_deref(), header.alg)
885            .await?;
886        self.verify(token, header.alg, &key)
887    }
888
889    /// [`OAuthValidator::validate`] against the keys already held, never
890    /// fetching: [`CachedAttempt::NeedsKeyFetch`] when every header check passed
891    /// but no cached key fits. Everything else — header refusals, signature and
892    /// claim checks, scope — is exactly `validate`'s, in the same order.
893    ///
894    /// [`crate::authenticate()`] runs this over every candidate first, so a
895    /// candidate whose key is cached is decided before any other candidate's
896    /// unknown `kid` can queue the request behind a JWKS refetch.
897    pub(crate) async fn validate_cached(&self, token: &str) -> CachedAttempt {
898        let span = validation_span!("oauth_rs.validate_cached");
899        if span.is_disabled() {
900            return self.validate_cached_in(token, &span).await;
901        }
902        let attempt = self
903            .validate_cached_in(token, &span)
904            .instrument(span.clone())
905            .await;
906        match &attempt {
907            CachedAttempt::Decided(result) => record_validation(&span, result.as_ref().err()),
908            CachedAttempt::NeedsKeyFetch => {
909                record_field(&span, "auth.outcome", "needs_key_fetch");
910            }
911        }
912        attempt
913    }
914
915    /// The body of [`OAuthValidator::validate_cached`].
916    async fn validate_cached_in(&self, token: &str, span: &Span) -> CachedAttempt {
917        let header = match self.check_header(token, span) {
918            Ok(header) => header,
919            Err(rejection) => return CachedAttempt::Decided(Err(rejection)),
920        };
921        match self
922            .keys
923            .cached_decoding_key(header.kid.as_deref(), header.alg)
924            .await
925        {
926            Some(key) => CachedAttempt::Decided(self.verify(token, header.alg, &key)),
927            None => CachedAttempt::NeedsKeyFetch,
928        }
929    }
930
931    /// Everything that can be refused from the unverified header alone (size,
932    /// shape, `crit`, `alg` allowlist, `typ`), before any key is looked up.
933    ///
934    /// Records the header's `kid` and `alg` on `span` (the validation's own,
935    /// or `Span::none()`), bounded and escaped by [`for_log_field`] — the
936    /// header is unverified, attacker-chosen data — as soon as it parses, so
937    /// a refusal for the `alg` itself still shows it. Nothing is formatted
938    /// when `span` is disabled.
939    pub(crate) fn check_header(
940        &self,
941        token: &str,
942        span: &Span,
943    ) -> Result<CheckedHeader, TokenRejection> {
944        if token.is_empty() {
945            return Err(TokenRejection::Missing);
946        }
947        if token.len() > MAX_TOKEN_BYTES {
948            return Err(TokenRejection::invalid(
949                InvalidTokenKind::TooLarge,
950                format!(
951                    "credential is {} bytes, over the {MAX_TOKEN_BYTES}-byte cap",
952                    token.len()
953                ),
954            ));
955        }
956        if token.split('.').count() != 3 {
957            // The single most useful hint in this crate for a new deployment:
958            // Authelia (by default), Ory Hydra (by default) and others issue OPAQUE
959            // access tokens, which no amount of JWKS can verify. (This is where an
960            // RFC 7662 introspection backend would take over; see the type docs.)
961            return Err(TokenRejection::invalid(
962                InvalidTokenKind::NotJwt,
963                "credential is not a JWT (a mistyped static token, or an opaque access \
964                 token — this server validates JWT access tokens only; configure the \
965                 authorization server to issue JWT access tokens)",
966            ));
967        }
968
969        // The header is unverified data. It is read only to pick which key to
970        // verify WITH; nothing from it is trusted afterwards, and `alg` is checked
971        // against our allowlist (and later against the key) rather than obeyed.
972        // `alg: none` does not even get this far: jsonwebtoken's `Algorithm` has no
973        // `none` variant, so the header fails to parse.
974        // The error text can echo attacker-supplied header content (an unknown
975        // `alg` string, verbatim), so it is truncated like every other
976        // token-derived string that reaches a log line.
977        let header = match decode_header(token) {
978            Ok(header) => header,
979            Err(e) => {
980                // The header jsonwebtoken cannot read (an unknown `alg` such
981                // as `none` or `HS1`, a missing one, bad base64) is where the
982                // span fields matter most: record what the raw JSON says.
983                if !span.is_disabled() {
984                    let (kid, alg) = raw_header_fields(token);
985                    if let Some(kid) = kid {
986                        record_field(span, "kid", for_log_field(&kid).as_str());
987                    }
988                    if let Some(alg) = alg {
989                        record_field(span, "alg", for_log_field(&alg).as_str());
990                    }
991                }
992                return Err(TokenRejection::invalid(
993                    InvalidTokenKind::MalformedHeader,
994                    format!("malformed token header: {}", for_log(&e.to_string())),
995                ));
996            }
997        };
998        if !span.is_disabled() {
999            if let Some(kid) = header.kid.as_deref() {
1000                record_field(span, "kid", for_log_field(kid).as_str());
1001            }
1002            // The crate's own label; an HMAC `alg` (no `Algorithm` variant)
1003            // is read from the raw header instead of jsonwebtoken's `Debug`.
1004            match Algorithm::from_jwt(header.alg) {
1005                Some(alg) => record_field(span, "alg", alg.as_str()),
1006                None => {
1007                    if let (_, Some(alg)) = raw_header_fields(token) {
1008                        record_field(span, "alg", for_log_field(&alg).as_str());
1009                    }
1010                }
1011            }
1012        }
1013        check_crit(token)?;
1014        // jsonwebtoken reads `"kid": null` as no `kid` at all, which would
1015        // send the token down the kid-less single-key fallback. A `kid` that
1016        // is present is a string or the header is malformed.
1017        if header.kid.is_none() && raw_header_has_member(token, "kid") {
1018            return Err(TokenRejection::invalid(
1019                InvalidTokenKind::MalformedHeader,
1020                "malformed token header: kid is present but not a string",
1021            ));
1022        }
1023        let alg = Algorithm::from_jwt(header.alg)
1024            .filter(|_| self.jwt_algorithms.contains(&header.alg))
1025            .ok_or_else(|| {
1026                TokenRejection::invalid(
1027                    InvalidTokenKind::AlgorithmNotAllowed,
1028                    format!(
1029                        "token algorithm {:?} is not in {}",
1030                        header.alg,
1031                        self.config.key_naming.key("algorithms")
1032                    ),
1033                )
1034            })?;
1035        check_typ(
1036            header.typ.as_deref(),
1037            self.config.require_at_jwt,
1038            &self.config.key_naming,
1039        )?;
1040        Ok(CheckedHeader {
1041            kid: header.kid,
1042            alg,
1043        })
1044    }
1045
1046    /// Signature, then issuer / audience / expiry / not-before, then the
1047    /// claims the decoder does not police (`iss` shape, `nbf` type, `cnf`),
1048    /// then the opt-in claim policy, then scope, against `key` — which [`OAuthValidator::check_header`]'s
1049    /// output selected.
1050    fn verify(
1051        &self,
1052        token: &str,
1053        alg: Algorithm,
1054        key: &DecodingKey,
1055    ) -> Result<AuthorizedToken, TokenRejection> {
1056        let mut validation = self.validation.clone();
1057        validation.algorithms = vec![alg.to_jwt()];
1058        let data = decode::<Map<String, Value>>(token, key, &validation).map_err(|e| {
1059            // `jsonwebtoken`'s error kinds already distinguish bad signature from
1060            // bad issuer/audience/expiry; all of them are 401 `invalid_token` to the
1061            // caller, and only the log gets to know which.
1062            TokenRejection::invalid(
1063                decode_error_kind(e.kind(), token),
1064                format!("token rejected: {e}"),
1065            )
1066        })?;
1067        let claims = data.claims;
1068
1069        // Belt and braces on `iss`: jsonwebtoken also accepts an `iss` ARRAY that
1070        // merely contains the configured issuer. RFC 7519 makes `iss` a single
1071        // StringOrURI, and "one of several issuers" is not a shape any real AS
1072        // emits, so anything but the exact string is refused.
1073        if claims.get("iss").and_then(Value::as_str) != Some(self.config.issuer.as_str()) {
1074            return Err(TokenRejection::invalid(
1075                InvalidTokenKind::WrongIssuer,
1076                format!(
1077                    "token iss is not a single string equal to {}",
1078                    self.config.key_naming.key("issuer")
1079                ),
1080            ));
1081        }
1082
1083        // RFC 7519 §4.1.5: `nbf` is a NumericDate, and the token MUST NOT be
1084        // accepted before it. jsonwebtoken checks it only when it reads as a
1085        // number and silently skips anything else (a string, a negative or
1086        // out-of-range value), which would turn a not-yet-valid token into a
1087        // valid one. Anything it could not have checked is refused here.
1088        if let Some(nbf) = claims.get("nbf")
1089            && !nbf_is_numeric_date(nbf)
1090        {
1091            return Err(TokenRejection::invalid(
1092                InvalidTokenKind::MalformedClaim,
1093                "token nbf is not a NumericDate (a non-negative number of seconds)",
1094            ));
1095        }
1096
1097        // A `cnf` (confirmation) claim binds the token to a key the client must
1098        // prove it holds: DPoP (RFC 9449, `jkt`) or an mTLS certificate (RFC
1099        // 8705, `x5t#S256`). This crate verifies neither proof, so accepting the
1100        // token as a plain bearer token would undo the binding the
1101        // authorization server set up — exactly what RFC 9449 §7.2 and RFC 8705
1102        // §3 forbid a resource server to do.
1103        if claims.contains_key("cnf") {
1104            return Err(TokenRejection::invalid(
1105                InvalidTokenKind::SenderConstrained,
1106                "token is sender-constrained (cnf); this server accepts bearer tokens only",
1107            ));
1108        }
1109
1110        // The opt-in claim policy (`allowed_client_ids`, `max_token_age_secs`,
1111        // `required_claims`). It reads `claims`, which only exist once `decode`
1112        // has verified the signature, and runs before the scope check so a
1113        // token that fails it is a 401, never a 403.
1114        self.check_claim_policy(&claims)?;
1115
1116        let scopes = extract_scopes(&claims, &self.config.scope_claims);
1117        let principal = extract_principal(&claims, &self.config.principal_claims);
1118        let subject = claims
1119            .get("sub")
1120            .and_then(Value::as_str)
1121            .map(str::to_string);
1122
1123        // All-of: every required scope must be present. An empty requirement
1124        // passes every token.
1125        // `missing_scopes` is the one matching rule, shared with
1126        // `AuthorizedToken::require_scopes`.
1127        if !missing_scopes(
1128            &scopes,
1129            self.config.required_scopes.iter().map(String::as_str),
1130        )
1131        .is_empty()
1132        {
1133            // Info, not debug: this is the refusal an operator wiring up a new
1134            // authorization server hits first (Authelia's `scp`-only tokens were
1135            // exactly this), and `present=[]` next to the claims that were
1136            // read is most of the diagnosis. Scopes are not secret.
1137            info!(
1138                principal = ?principal.as_deref().map(for_log),
1139                required = %self.required_scopes,
1140                present = ?crate::token::scopes_for_log(&scopes),
1141                scope_claims = ?self.config.scope_claims,
1142                "OAuth token is valid but lacks the required scope"
1143            );
1144            return Err(TokenRejection::InsufficientScope);
1145        }
1146
1147        Ok(AuthorizedToken::from_verified_claims(
1148            claims, subject, principal, scopes,
1149        ))
1150    }
1151
1152    /// The default-off checks on verified claims: the client allowlist, the
1153    /// maximum token age and the required claims, in that order. Called by
1154    /// [`OAuthValidator::verify`] only, on the claim set its `decode` produced.
1155    fn check_claim_policy(&self, claims: &Map<String, Value>) -> Result<(), TokenRejection> {
1156        let config = &self.config;
1157        if !config.allowed_client_ids.is_empty() {
1158            // Stricter than `client_id_of` (the `AuthorizedToken::client_id`
1159            // accessor, which reads the first non-empty string): a `client_id`
1160            // that is present but not a non-empty string is refused here
1161            // rather than falling through to `azp`, so a token cannot pick
1162            // which claim the allowlist reads by blanking or mistyping one.
1163            if let Some(raw) = claims.get("client_id")
1164                && raw.as_str().is_none_or(str::is_empty)
1165            {
1166                return Err(TokenRejection::invalid(
1167                    InvalidTokenKind::ClientNotAllowed,
1168                    format!(
1169                        "token client_id is present but not a non-empty string, and {} is set",
1170                        config.key_naming.key("allowed_client_ids")
1171                    ),
1172                ));
1173            }
1174            match client_id_of(claims) {
1175                Some(client) if config.allowed_client_ids.iter().any(|c| c == client) => {}
1176                Some(client) => {
1177                    return Err(TokenRejection::invalid(
1178                        InvalidTokenKind::ClientNotAllowed,
1179                        format!(
1180                            "token client {:?} is not in {}",
1181                            for_log(client),
1182                            config.key_naming.key("allowed_client_ids")
1183                        ),
1184                    ));
1185                }
1186                None => {
1187                    return Err(TokenRejection::invalid(
1188                        InvalidTokenKind::ClientNotAllowed,
1189                        format!(
1190                            "token names no client (client_id or azp) and {} is set",
1191                            config.key_naming.key("allowed_client_ids")
1192                        ),
1193                    ));
1194                }
1195            }
1196        }
1197
1198        if let Some(max_age) = config.max_token_age_secs {
1199            let key = config.key_naming.key("max_token_age_secs");
1200            let Some(iat) = claims.get("iat") else {
1201                return Err(TokenRejection::invalid(
1202                    InvalidTokenKind::MissingClaim,
1203                    format!("token has no iat and {key} is set"),
1204                ));
1205            };
1206            let Some(iat) = numeric_date_secs(iat) else {
1207                return Err(TokenRejection::invalid(
1208                    InvalidTokenKind::MalformedClaim,
1209                    "token iat is not a NumericDate (a non-negative number of seconds)",
1210                ));
1211            };
1212            let now = std::time::SystemTime::now()
1213                .duration_since(std::time::UNIX_EPOCH)
1214                .map_or(0, |d| d.as_secs());
1215            let leeway = config.leeway_secs;
1216            if iat > now.saturating_add(leeway) {
1217                return Err(TokenRejection::invalid(
1218                    InvalidTokenKind::NotYetValid,
1219                    format!("token iat is {}s in the future", iat - now),
1220                ));
1221            }
1222            let age = now.saturating_sub(iat);
1223            if age > max_age.saturating_add(leeway) {
1224                return Err(TokenRejection::invalid(
1225                    InvalidTokenKind::TokenTooOld,
1226                    format!("token was issued {age}s ago, over {key} {max_age}"),
1227                ));
1228            }
1229        }
1230
1231        for (name, required) in &config.required_claims {
1232            let key = config.key_naming.key("required_claims");
1233            match claims.get(name) {
1234                None => {
1235                    return Err(TokenRejection::invalid(
1236                        InvalidTokenKind::MissingClaim,
1237                        format!(
1238                            "token has no {:?} claim, which {key} requires",
1239                            for_log(name)
1240                        ),
1241                    ));
1242                }
1243                Some(actual) if claim_matches(actual, required) => {}
1244                Some(_) => {
1245                    return Err(TokenRejection::invalid(
1246                        InvalidTokenKind::ClaimMismatch,
1247                        format!("token claim {:?} does not match {key}", for_log(name)),
1248                    ));
1249                }
1250            }
1251        }
1252        Ok(())
1253    }
1254
1255    /// Load (or reload) the key set now, discovering the JWKS URI first if needed.
1256    /// Returns how many usable keys it holds. On failure the previous keys are
1257    /// kept — a transient IdP outage must not invalidate keys that are still good.
1258    ///
1259    /// Useful for a startup step that waits for the keys, or a test. It fetches
1260    /// every time, so a probe should call [`OAuthValidator::is_ready`] or
1261    /// [`OAuthValidator::key_set_status`] instead, which do no I/O.
1262    /// [`OAuthValidator::spawn_background_refresh`] already calls it
1263    /// once at startup and then hourly. The fetch runs in a task of its own,
1264    /// so dropping this future does not cancel it.
1265    ///
1266    /// # Errors
1267    ///
1268    /// [`RefreshError`] when discovery fails (no metadata document, or one for
1269    /// a different issuer), the JWKS cannot be fetched (network, TLS, status,
1270    /// size cap, not JSON), or the key set holds no key usable with the
1271    /// configured algorithms. Its `Display` includes the whole cause chain.
1272    ///
1273    /// # Panics
1274    ///
1275    /// Outside a Tokio 1.x runtime (see [Runtime](OAuthValidator#runtime)).
1276    pub async fn refresh_now(&self) -> Result<usize, RefreshError> {
1277        self.keys.refresh_now().await
1278    }
1279
1280    /// A snapshot of the signing keys this validator holds: how many, the
1281    /// JWKS URL in use, when a refresh was last attempted and last succeeded,
1282    /// and why the last one failed, if it did.
1283    ///
1284    /// Passive: it does no I/O, never takes the refresh lock and never waits
1285    /// on a refresh in flight — it copies a few fields under a lock that is
1286    /// only ever held for such a copy. Unlike [`OAuthValidator::refresh_now`]
1287    /// it is cheap enough for a readiness probe, a status page or a metrics
1288    /// scrape to call on every request, and needs no Tokio runtime. It only
1289    /// reports: nothing loads keys unless
1290    /// [`OAuthValidator::spawn_background_refresh`] runs (or a request or
1291    /// [`OAuthValidator::refresh_now`] triggers a fetch), so a probe gating on
1292    /// it needs that task. The `jwks_uri` and any error message in it are
1293    /// redacted (see [`RefreshError`]).
1294    ///
1295    /// # Examples
1296    ///
1297    /// A status report for an operator-facing page:
1298    ///
1299    /// ```
1300    /// use std::time::SystemTime;
1301    ///
1302    /// use oauth_resource_server::OAuthValidator;
1303    ///
1304    /// fn key_report(validator: &OAuthValidator) -> String {
1305    ///     let status = validator.key_set_status();
1306    ///     let age = status
1307    ///         .last_success
1308    ///         .and_then(|t| SystemTime::now().duration_since(t).ok())
1309    ///         .map_or("never".to_string(), |d| format!("{}s ago", d.as_secs()));
1310    ///     let error = match &status.last_error {
1311    ///         // `kind()` is safe to show anyone; the full `Display` (URLs and
1312    ///         // upstream error text) is for logs and operators.
1313    ///         Some(e) => e.kind().as_str(),
1314    ///         None => "none",
1315    ///     };
1316    ///     format!(
1317    ///         "{} key(s) from {}, loaded {age}, last error: {error}",
1318    ///         status.keys,
1319    ///         status.jwks_uri.as_deref().unwrap_or("(not yet discovered)"),
1320    ///     )
1321    /// }
1322    /// # let resolved = oauth_resource_server::OAuthConfig {
1323    /// #     enabled: true,
1324    /// #     issuer: "https://auth.example.com/".into(),
1325    /// #     jwks_uri: Some("https://auth.example.com/jwks".into()),
1326    /// #     audience: "example-api".into(),
1327    /// #     resource: "https://api.example.com/".into(),
1328    /// #     required_scope: Some("api:read".into()),
1329    /// #     ..Default::default()
1330    /// # }
1331    /// # .resolve(oauth_resource_server::KeyNaming::Dotted("oauth"))
1332    /// # .unwrap()
1333    /// # .unwrap();
1334    /// # let validator = OAuthValidator::new(&resolved).unwrap();
1335    /// // Before any key load:
1336    /// assert_eq!(
1337    ///     key_report(&validator),
1338    ///     "0 key(s) from https://auth.example.com/jwks, loaded never, last error: none"
1339    /// );
1340    /// ```
1341    pub fn key_set_status(&self) -> KeySetStatus {
1342        self.keys.status()
1343    }
1344
1345    /// At least one usable signing key is held, so a token signed by it can be
1346    /// validated. Passive, like [`OAuthValidator::key_set_status`]: no I/O, no
1347    /// waiting on a refresh.
1348    ///
1349    /// It never goes back to `false` once `true`: a failed refresh keeps the
1350    /// keys already held (see [`OAuthValidator::refresh_now`]). That makes it
1351    /// the right readiness signal, and the wrong liveness one — see the
1352    /// README's "Readiness and liveness probes".
1353    ///
1354    /// Gate readiness on it only with [`OAuthValidator::spawn_background_refresh`]
1355    /// running (or at least a startup [`OAuthValidator::refresh_now`]).
1356    /// Otherwise keys load only when a request brings a token — and a
1357    /// not-ready process gets no requests, so it would never become ready.
1358    ///
1359    /// # Examples
1360    ///
1361    /// ```
1362    /// use oauth_resource_server::OAuthValidator;
1363    ///
1364    /// /// The status code for a readiness endpoint.
1365    /// fn readiness(validator: &OAuthValidator) -> u16 {
1366    ///     if validator.is_ready() { 200 } else { 503 }
1367    /// }
1368    /// # let resolved = oauth_resource_server::OAuthConfig {
1369    /// #     enabled: true,
1370    /// #     issuer: "https://auth.example.com/".into(),
1371    /// #     audience: "example-api".into(),
1372    /// #     resource: "https://api.example.com/".into(),
1373    /// #     required_scope: Some("api:read".into()),
1374    /// #     ..Default::default()
1375    /// # }
1376    /// # .resolve(oauth_resource_server::KeyNaming::Dotted("oauth"))
1377    /// # .unwrap()
1378    /// # .unwrap();
1379    /// # let validator = OAuthValidator::new(&resolved).unwrap();
1380    /// assert_eq!(readiness(&validator), 503); // no key loaded yet
1381    /// ```
1382    pub fn is_ready(&self) -> bool {
1383        self.keys.has_keys()
1384    }
1385
1386    /// Warm the key cache at startup and keep it fresh; returns the task's handle.
1387    ///
1388    /// The first pass turns a misconfigured issuer, an unreachable JWKS or a
1389    /// discovery mismatch into one clear log line at boot instead of a wall of
1390    /// 401s on the first real request — without making startup itself depend on
1391    /// the authorization server being up (a restart during an IdP outage must not
1392    /// take this service down too). Later passes, hourly, are what drop a key the
1393    /// AS has withdrawn — once one succeeds: a failed pass keeps every key held,
1394    /// and is retried after a minute, backing off to an hour.
1395    ///
1396    /// While no key is held at all (the first load failed and nothing has
1397    /// succeeded since), a failed pass is retried sooner: after 5 s, doubling
1398    /// to at most 5 minutes. A keyless validator refuses every token, and a
1399    /// readiness probe on [`OAuthValidator::is_ready`] keeps the traffic that
1400    /// would otherwise trigger a refetch away from it, so this schedule is
1401    /// what brings it back once the authorization server recovers. These
1402    /// retries are timer-driven only; nothing in a request can schedule one.
1403    ///
1404    /// The first load logs `OAuth: authorization server signing keys loaded` at
1405    /// `info`, or `OAuth: could not load the authorization server's signing
1406    /// keys` at `warn`. The task holds only a weak reference between passes: it
1407    /// stops once the last `Arc` of this validator is dropped (or when the
1408    /// returned handle is aborted), so rebuilding a validator does not leave the
1409    /// old one polling. Dropping the handle alone does not stop it.
1410    ///
1411    /// # Panics
1412    ///
1413    /// When called outside a Tokio 1.x runtime (it uses `tokio::spawn`).
1414    pub fn spawn_background_refresh(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
1415        let weak = Arc::downgrade(self);
1416        let mut alive = self.alive.subscribe();
1417        tokio::spawn(async move {
1418            let mut first = true;
1419            let mut failures: u32 = 0;
1420            loop {
1421                let Some(this) = weak.upgrade() else {
1422                    return;
1423                };
1424                let wait = match this.refresh_now().await {
1425                    Ok(count) => {
1426                        if first {
1427                            info!(
1428                                issuer = %redact_url(&this.config.issuer),
1429                                keys = count,
1430                                "OAuth: authorization server signing keys loaded"
1431                            );
1432                        } else {
1433                            debug!(keys = count, "OAuth: signing keys refreshed");
1434                        }
1435                        failures = 0;
1436                        JWKS_BACKGROUND_REFRESH_INTERVAL
1437                    }
1438                    Err(e) => {
1439                        failures = failures.saturating_add(1);
1440                        let wait = if this.keys.has_keys() {
1441                            background_retry_delay(failures)
1442                        } else {
1443                            keyless_retry_delay(failures)
1444                        };
1445                        warn!(
1446                            issuer = %redact_url(&this.config.issuer),
1447                            error = %e,
1448                            retry_in_secs = wait.as_secs(),
1449                            "OAuth: could not load the authorization server's signing keys — \
1450                             tokens signed by a key this server does not already hold will be \
1451                             rejected (401) until a later attempt succeeds. Check {} / {} and \
1452                             that this host can reach them.",
1453                            this.config.key_naming.key("issuer"),
1454                            this.config.key_naming.key("jwks_uri")
1455                        );
1456                        wait
1457                    }
1458                };
1459                first = false;
1460                // Only the weak reference survives the wait, so the validator
1461                // can be dropped meanwhile — which drops `alive`'s sender and
1462                // ends the wait at once.
1463                drop(this);
1464                if tokio::time::timeout(wait, alive.changed()).await.is_ok() {
1465                    return;
1466                }
1467            }
1468        })
1469    }
1470}
1471
1472/// The header fields a validation carries forward: the `kid` to look the key
1473/// up by, and the allowlisted algorithm.
1474pub(crate) struct CheckedHeader {
1475    kid: Option<String>,
1476    alg: Algorithm,
1477}
1478
1479/// The unverified header's `kid` and `alg` as strings, read from its raw
1480/// JSON: for a span field only, when jsonwebtoken's `Header` cannot say (an
1481/// `alg` it does not know, or an HMAC one this crate has no `Algorithm`
1482/// for). Called after the size cap and the three-segment check, so the
1483/// segment is at most `MAX_TOKEN_BYTES`; a value that is not a string, or a
1484/// header that is not base64url JSON, gives `None`. The caller bounds and
1485/// escapes the result with `for_log_field`.
1486fn raw_header_fields(token: &str) -> (Option<String>, Option<String>) {
1487    let segment = token.split('.').next().unwrap_or_default();
1488    let Some(header) = URL_SAFE_NO_PAD
1489        .decode(segment)
1490        .ok()
1491        .and_then(|bytes| serde_json::from_slice::<Map<String, Value>>(&bytes).ok())
1492    else {
1493        return (None, None);
1494    };
1495    let field = |name: &str| header.get(name).and_then(Value::as_str).map(str::to_owned);
1496    (field("kid"), field("alg"))
1497}
1498
1499/// Whether the unverified header's raw JSON has a member `name`, whatever its
1500/// value (`null` included). Called after `decode_header` succeeded, so the
1501/// segment is base64url JSON of at most the 16 KiB credential cap.
1502fn raw_header_has_member(token: &str, name: &str) -> bool {
1503    let segment = token.split('.').next().unwrap_or_default();
1504    URL_SAFE_NO_PAD
1505        .decode(segment)
1506        .ok()
1507        .and_then(|bytes| serde_json::from_slice::<Map<String, Value>>(&bytes).ok())
1508        .is_some_and(|header| header.contains_key(name))
1509}
1510
1511/// RFC 7515 §4.1.11: a recipient that does not understand every extension a
1512/// JWS lists in `crit` MUST treat it as invalid. This crate understands none,
1513/// so any `crit` — including an empty or malformed one — is refused.
1514/// jsonwebtoken's `Header` has no `crit` field and drops it silently, so the
1515/// protected header is read raw here. Called after `decode_header` succeeded,
1516/// so the segment is known to be base64url JSON; it is at most the 16 KiB
1517/// credential cap.
1518pub(crate) fn check_crit(token: &str) -> Result<(), TokenRejection> {
1519    let segment = token.split('.').next().unwrap_or_default();
1520    let header: Map<String, Value> = URL_SAFE_NO_PAD
1521        .decode(segment)
1522        .ok()
1523        .and_then(|raw| serde_json::from_slice(&raw).ok())
1524        .ok_or_else(|| {
1525            TokenRejection::invalid(
1526                InvalidTokenKind::MalformedHeader,
1527                "malformed token header: not a base64url JSON object",
1528            )
1529        })?;
1530    if header.contains_key("crit") {
1531        return Err(TokenRejection::invalid(
1532            InvalidTokenKind::CriticalHeader,
1533            "token header lists critical extensions (crit), none of which this server supports",
1534        ));
1535    }
1536    Ok(())
1537}
1538
1539/// The [`InvalidTokenKind`] for a `jsonwebtoken::decode` failure. The header
1540/// was already parsed and its `alg` allowlisted by `check_header`, so what is
1541/// left is the signature, the payload's encoding and the claim checks.
1542///
1543/// jsonwebtoken reports a present-but-unreadable `exp`, `iss` or `aud` (a
1544/// string `exp`, a numeric `iss`) as `MissingRequiredClaim`, the same as an
1545/// absent one. It checks claims only after the signature verified, so the
1546/// payload is signed by then; it is re-read here only to tell the two apart
1547/// (`MalformedClaim` when the claim is there), never to trust anything in it.
1548///
1549/// For the same reason a JSON error on a payload that DOES decode as a JSON
1550/// object is `MalformedClaim`: jsonwebtoken reads `exp`/`nbf`/`iss`/`aud`
1551/// through a typed struct of its own, so `"nbf": [1]` fails there with a
1552/// JSON error although the payload itself is fine JSON. A payload that is
1553/// not a JSON object at all stays `MalformedToken`.
1554fn decode_error_kind(kind: &jsonwebtoken::errors::ErrorKind, token: &str) -> InvalidTokenKind {
1555    use jsonwebtoken::errors::ErrorKind as E;
1556    let payload_claims = || {
1557        token
1558            .split('.')
1559            .nth(1)
1560            .and_then(|payload| URL_SAFE_NO_PAD.decode(payload).ok())
1561            .and_then(|raw| serde_json::from_slice::<Map<String, Value>>(&raw).ok())
1562    };
1563    if let E::MissingRequiredClaim(name) = kind {
1564        let present = payload_claims().is_some_and(|claims| claims.contains_key(name));
1565        return if present {
1566            InvalidTokenKind::MalformedClaim
1567        } else {
1568            InvalidTokenKind::MissingClaim
1569        };
1570    }
1571    if matches!(kind, E::Json(_)) && payload_claims().is_some() {
1572        return InvalidTokenKind::MalformedClaim;
1573    }
1574    match kind {
1575        E::InvalidSignature
1576        | E::InvalidEcdsaKey
1577        | E::InvalidRsaKey(_)
1578        | E::InvalidKeyFormat
1579        | E::Crypto(_) => InvalidTokenKind::BadSignature,
1580        E::ExpiredSignature => InvalidTokenKind::Expired,
1581        E::ImmatureSignature => InvalidTokenKind::NotYetValid,
1582        E::InvalidIssuer => InvalidTokenKind::WrongIssuer,
1583        E::InvalidAudience => InvalidTokenKind::WrongAudience,
1584        E::MissingRequiredClaim(_) => InvalidTokenKind::MissingClaim,
1585        E::InvalidToken | E::Base64(_) | E::Json(_) | E::Utf8(_) => {
1586            InvalidTokenKind::MalformedToken
1587        }
1588        E::InvalidAlgorithm | E::MissingAlgorithm | E::InvalidAlgorithmName => {
1589            InvalidTokenKind::AlgorithmNotAllowed
1590        }
1591        // `InvalidSubject` (no `sub` check is configured), `RsaFailedSigning`
1592        // (signing only) and any kind a later jsonwebtoken adds. Still a
1593        // refusal: the kind only labels it.
1594        _ => InvalidTokenKind::Other,
1595    }
1596}
1597
1598/// Whether a token's claim satisfies a `required_claims` value: equal to it
1599/// (JSON equality), or an array with an element equal to it. `required` is a
1600/// string, number or boolean (`resolve` refuses anything else); a hand-edited
1601/// `null`, array or object is matched by the same rule, never widened.
1602fn claim_matches(actual: &Value, required: &Value) -> bool {
1603    actual == required
1604        || matches!(actual, Value::Array(items) if items.iter().any(|item| item == required))
1605}
1606
1607/// Whether `nbf` is a NumericDate jsonwebtoken actually checks: a non-negative
1608/// number it can read as whole seconds.
1609fn nbf_is_numeric_date(nbf: &Value) -> bool {
1610    nbf.as_u64().is_some()
1611        || nbf
1612            .as_f64()
1613            .is_some_and(|f| f.is_finite() && f >= 0.0 && f < u64::MAX as f64)
1614}
1615
1616/// The scope/`typ` posture a validator was built with; see the warning it
1617/// drives in [`OAuthValidator::build`].
1618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1619enum UnscopedPosture {
1620    /// At least one required scope: an ID token (which carries no scope claim
1621    /// on any mainstream server) is refused with 403.
1622    Scoped,
1623    /// No required scope, but `require_at_jwt` refuses anything not typed as
1624    /// an access token.
1625    UnscopedButTypEnforced,
1626    /// No required scope and no `typ` enforcement: an ID token for the same
1627    /// client (`aud` = client_id) is indistinguishable from an access token.
1628    IdTokensAccepted,
1629}
1630
1631fn unscoped_posture(config: &ResolvedOAuthConfig) -> UnscopedPosture {
1632    match (config.required_scopes.is_empty(), config.require_at_jwt) {
1633        (false, _) => UnscopedPosture::Scoped,
1634        (true, true) => UnscopedPosture::UnscopedButTypEnforced,
1635        (true, false) => UnscopedPosture::IdTokensAccepted,
1636    }
1637}
1638
1639/// One startup warning per URL setting spelled in a form the URL parser has to
1640/// repair ([`non_canonical_url`]), naming the setting and giving both spellings
1641/// through [`redact_url`]. Not refused by `resolve`: reqwest fetches such a URL
1642/// correctly, and refusing it would narrow what 0.1.x accepted.
1643pub(crate) fn non_canonical_warnings(config: &ResolvedOAuthConfig) -> Vec<String> {
1644    let naming = &config.key_naming;
1645    let urls = [
1646        ("issuer", Some(config.issuer.as_str())),
1647        ("resource", Some(config.resource.as_str())),
1648        ("jwks_uri", config.jwks_uri.as_deref()),
1649    ];
1650    urls.into_iter()
1651        .filter_map(|(name, value)| {
1652            let value = value?;
1653            let canonical = non_canonical_url(value)?;
1654            let consequence = match name {
1655                "issuer" => {
1656                    "a token's `iss` is compared with it byte-for-byte, and no authorization \
1657                     server writes its issuer this way, so every token will be refused; \
1658                     discovery URLs are built from the raw text too"
1659                }
1660                "resource" => {
1661                    "the `resource_metadata` URL in every challenge, the metadata document \
1662                     and the route it is served on are built from the raw text, so clients \
1663                     may not find the metadata, or may reject it"
1664                }
1665                _ => "logs and key-set status show the raw text, not the URL fetched",
1666            };
1667            Some(format!(
1668                "{} {:?} is not canonically spelled — it is read as {:?}; {consequence}. \
1669                 Write it in that form.",
1670                naming.key(name),
1671                redact_url(value),
1672                redact_url(&canonical),
1673            ))
1674        })
1675        .collect()
1676}
1677
1678/// `required_claims` names that work but are probably a mistake, with why:
1679/// a scope claim is matched as a whole value (a space-delimited `scope` string
1680/// equals `"api:read"` only when that is its only scope), and `azp` /
1681/// `client_id` sidestep `allowed_client_ids`' `client_id`-then-`azp`
1682/// precedence. Logged once at startup; never refused, since each can be
1683/// deliberate.
1684fn required_claim_footguns(config: &ResolvedOAuthConfig) -> Vec<(&str, &'static str)> {
1685    config
1686        .required_claims
1687        .keys()
1688        .filter_map(|name| {
1689            let why = if name == "scope"
1690                || name == "scp"
1691                || config.scope_claims.iter().any(|c| c == name)
1692            {
1693                "a scope claim is compared as one whole value (a space-delimited string \
1694                 matches only when it holds exactly that value); require scopes with \
1695                 required_scopes instead"
1696            } else if name == "azp" || name == "client_id" {
1697                "requiring one client claim bypasses allowed_client_ids' client_id-then-azp \
1698                 precedence; restrict clients with allowed_client_ids instead"
1699            } else {
1700                return None;
1701            };
1702            Some((name.as_str(), why))
1703        })
1704        .collect()
1705}
1706
1707/// Required scopes a client is never told to request: those missing from a
1708/// non-empty `scopes_supported`. Empty when `scopes_supported` is empty, since
1709/// the challenge then advertises the required scopes themselves.
1710fn unadvertised_scopes(config: &ResolvedOAuthConfig) -> Vec<&str> {
1711    if config.scopes_supported.is_empty() {
1712        return Vec::new();
1713    }
1714    config
1715        .required_scopes
1716        .iter()
1717        .filter(|s| !config.scopes_supported.contains(s))
1718        .map(String::as_str)
1719        .collect()
1720}
1721
1722/// Whether `url` is plain `http` to a host other than loopback/`localhost`.
1723///
1724/// Decided on the PARSED URL — trimmed and parsed exactly as `check_url` and
1725/// reqwest parse it — never on the raw text: the parser reads `http:/host`,
1726/// `http:host`, `HTTP:\\host\path` and ` http://host` all as
1727/// `http://host/...`, which is where reqwest would connect. A value that does
1728/// not parse is not a URL anything would fetch, so it is not "plain http".
1729pub(crate) fn plain_http_non_loopback(url: &str) -> bool {
1730    reqwest::Url::parse(url.trim()).is_ok_and(|parsed| parsed_plain_http_non_loopback(&parsed))
1731}
1732
1733/// [`plain_http_non_loopback`] for a URL that is already parsed — a redirect
1734/// target, say — so the decision is made on the very value reqwest uses.
1735pub(crate) fn parsed_plain_http_non_loopback(url: &reqwest::Url) -> bool {
1736    url.scheme() == "http" && !is_loopback_url(url)
1737}
1738
1739/// The canonical form of `url` when it parses but is spelled in a way the URL
1740/// parser has to repair ([`is_canonical_spelling`]); `None` when it is
1741/// canonically spelled or does not parse at all. Feeds the startup warnings;
1742/// code that takes a URL apart on `://` asks [`is_canonical_url`] instead,
1743/// which is false for an unparseable value too.
1744pub(crate) fn non_canonical_url(url: &str) -> Option<String> {
1745    let value = url.trim();
1746    let parsed = reqwest::Url::parse(value).ok()?;
1747    (!is_canonical_spelling(value, &parsed)).then(|| parsed.to_string())
1748}
1749
1750/// Whether `url` parses AND is canonically spelled — the only case in which
1751/// splitting its raw text on `://` finds the authority the parser finds.
1752pub(crate) fn is_canonical_url(url: &str) -> bool {
1753    let value = url.trim();
1754    reqwest::Url::parse(value).is_ok_and(|parsed| is_canonical_spelling(value, &parsed))
1755}
1756
1757/// Whether `value` (which parsed as `parsed`) is written `scheme://authority`
1758/// with a non-empty authority containing no `\`.
1759///
1760/// The WHATWG parser reqwest uses reads `https:/host`, `https:host`,
1761/// `https:\\host` and `https:///host` all as `https://host/...`, so each is
1762/// fetched from `host` — but the RAW string is what is compared byte-for-byte
1763/// with a token's `iss`, taken apart on `://` to build discovery and
1764/// `resource_metadata` URLs, and echoed into every challenge, so such a value
1765/// means something different in each of those places. Only the authority is
1766/// checked for `\`: one in the path is read as `/`, which moves no host. Case,
1767/// a default port and a missing trailing slash are not flagged: they move
1768/// neither the authority nor the path, and an issuer must keep the exact
1769/// spelling its authorization server stamps into `iss`.
1770fn is_canonical_spelling(value: &str, parsed: &reqwest::Url) -> bool {
1771    let scheme = parsed.scheme();
1772    value
1773        .get(..scheme.len())
1774        .filter(|s| s.eq_ignore_ascii_case(scheme))
1775        .and_then(|_| value[scheme.len()..].strip_prefix("://"))
1776        .is_some_and(|rest| {
1777            let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
1778            !authority.is_empty() && !authority.contains('\\')
1779        })
1780}
1781
1782/// Whether `url`'s host is loopback, parsed exactly as
1783/// [`plain_http_non_loopback`] parses it (trimmed, then `reqwest::Url::parse`),
1784/// so a caller choosing an HTTP client agrees with `resolve`'s judgment for
1785/// every spelling. An unparseable value is not loopback.
1786pub(crate) fn url_is_loopback(url: &str) -> bool {
1787    reqwest::Url::parse(url.trim()).is_ok_and(|parsed| is_loopback_url(&parsed))
1788}
1789
1790/// Whether the parsed `url`'s host is a loopback address or `localhost`.
1791pub(crate) fn is_loopback_url(parsed: &reqwest::Url) -> bool {
1792    let Some(host) = parsed.host_str() else {
1793        return false;
1794    };
1795    let host = host.trim_start_matches('[').trim_end_matches(']');
1796    host == "localhost"
1797        || host.ends_with(".localhost")
1798        || host
1799            .parse::<std::net::IpAddr>()
1800            .is_ok_and(|ip| ip.is_loopback())
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805    use super::*;
1806    use crate::config::KeyNamingBuf;
1807    use crate::jwks::{MAX_FETCH_BYTES, RefreshErrorKind};
1808    use crate::testing::*;
1809    use std::collections::HashMap;
1810    use std::sync::atomic::Ordering;
1811
1812    fn oauth_config(jwks_uri: &str) -> ResolvedOAuthConfig {
1813        resolved_config(jwks_uri)
1814    }
1815
1816    /// Zero cooldown: a test that wants to observe a refetch should not have to
1817    /// sleep out `JWKS_MIN_REFETCH_INTERVAL`.
1818    fn validator_no_cooldown(jwks_uri: &str) -> OAuthValidator {
1819        OAuthValidator::build(&oauth_config(jwks_uri), Duration::ZERO).unwrap()
1820    }
1821
1822    fn validator(jwks_uri: &str) -> OAuthValidator {
1823        OAuthValidator::new(&oauth_config(jwks_uri)).unwrap()
1824    }
1825
1826    fn validator_with(cfg: ResolvedOAuthConfig) -> OAuthValidator {
1827        OAuthValidator::new(&cfg).unwrap()
1828    }
1829
1830    fn claims(extra: serde_json::Value) -> serde_json::Value {
1831        let mut base = serde_json::json!({
1832            "iss": ISSUER, "aud": AUDIENCE, "sub": "user-1", "exp": now() + 3600,
1833        });
1834        for (k, v) in extra.as_object().unwrap() {
1835            base[k] = v.clone();
1836        }
1837        base
1838    }
1839
1840    fn is_invalid<T: std::fmt::Debug>(r: &Result<T, TokenRejection>) -> bool {
1841        matches!(r, Err(TokenRejection::Invalid(_)))
1842    }
1843
1844    // ── construction ─────────────────────────────────────────────────────────
1845
1846    #[test]
1847    fn construction_refuses_an_empty_audience_set_or_algorithm_list() {
1848        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1849        cfg.audience = String::new();
1850        let err = OAuthValidator::new(&cfg).unwrap_err();
1851        assert!(matches!(err, ValidatorError::NoAudience { .. }));
1852        assert_eq!(
1853            err.to_string(),
1854            "mcp.oauth: no accepted audience configured"
1855        );
1856
1857        // OAuth fields at the root of the config: the block still gets a name.
1858        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1859        cfg.audience = String::new();
1860        cfg.key_naming = KeyNamingBuf::Dotted(String::new());
1861        let err = OAuthValidator::new(&cfg).unwrap_err();
1862        assert_eq!(
1863            err.to_string(),
1864            "OAuth config: no accepted audience configured"
1865        );
1866
1867        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1868        cfg.algorithms.clear();
1869        let err = OAuthValidator::new(&cfg).unwrap_err();
1870        assert_eq!(err.to_string(), "mcp.oauth.algorithms is empty");
1871
1872        cfg.key_naming = KeyNamingBuf::Env("APP_OAUTH_".into());
1873        let err = OAuthValidator::new(&cfg).unwrap_err();
1874        assert_eq!(err.to_string(), "APP_OAUTH_ALGORITHMS is empty");
1875    }
1876
1877    #[test]
1878    fn construction_refuses_a_leeway_over_the_cap_set_after_resolving() {
1879        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1880        cfg.leeway_secs = crate::MAX_LEEWAY_SECS;
1881        OAuthValidator::new(&cfg).expect("the cap itself is allowed");
1882
1883        for leeway in [crate::MAX_LEEWAY_SECS + 1, 86_400, u64::MAX] {
1884            cfg.leeway_secs = leeway;
1885            let err = OAuthValidator::new(&cfg).unwrap_err();
1886            assert!(
1887                matches!(err, ValidatorError::LeewayTooLarge { .. }),
1888                "{err}"
1889            );
1890            assert_eq!(
1891                err.to_string(),
1892                format!("mcp.oauth.leeway_secs {leeway} is over the 300-second cap")
1893            );
1894        }
1895    }
1896
1897    #[test]
1898    fn the_unscoped_posture_is_classified_for_the_startup_log() {
1899        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1900        assert!(!cfg.required_scopes.is_empty());
1901        assert_eq!(unscoped_posture(&cfg), UnscopedPosture::Scoped);
1902        cfg.require_at_jwt = true;
1903        assert_eq!(unscoped_posture(&cfg), UnscopedPosture::Scoped);
1904
1905        // No scope: only `typ` enforcement keeps an ID token out.
1906        cfg.required_scopes.clear();
1907        assert_eq!(
1908            unscoped_posture(&cfg),
1909            UnscopedPosture::UnscopedButTypEnforced
1910        );
1911        cfg.require_at_jwt = false;
1912        assert_eq!(unscoped_posture(&cfg), UnscopedPosture::IdTokensAccepted);
1913    }
1914
1915    /// The combination the startup warning is about: with no required scope and
1916    /// `require_at_jwt` off, an ID token (typ `JWT`, no scope claim) signed for
1917    /// the same client is accepted; either setting turns it away.
1918    #[tokio::test]
1919    async fn an_id_token_is_accepted_only_when_unscoped_and_typ_is_not_enforced() {
1920        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1921        let id_token = mint_with(
1922            Algorithm::RS256,
1923            Some(KID_A),
1924            Some("JWT"),
1925            &claims(serde_json::json!({ "nonce": "n-1", "auth_time": now() })),
1926        );
1927
1928        let mut cfg = oauth_config(&jwks.url);
1929        cfg.required_scopes.clear();
1930        assert!(
1931            validator_with(cfg.clone())
1932                .validate(&id_token)
1933                .await
1934                .is_ok()
1935        );
1936
1937        let mut scoped = cfg.clone();
1938        scoped.required_scopes = vec!["mcp:read".into()];
1939        assert_eq!(
1940            validator_with(scoped).validate(&id_token).await,
1941            Err(TokenRejection::InsufficientScope)
1942        );
1943
1944        cfg.require_at_jwt = true;
1945        assert!(is_invalid(&validator_with(cfg).validate(&id_token).await));
1946    }
1947
1948    #[test]
1949    fn plain_http_detection_exempts_loopback_only() {
1950        assert!(plain_http_non_loopback("http://idp.example.com/jwks"));
1951        assert!(plain_http_non_loopback("HTTP://idp.example.com/jwks"));
1952        assert!(!plain_http_non_loopback("https://idp.example.com/jwks"));
1953        assert!(!plain_http_non_loopback("http://127.0.0.1:9000/jwks"));
1954        assert!(!plain_http_non_loopback("http://localhost/jwks"));
1955        assert!(!plain_http_non_loopback("http://[::1]:9000/jwks"));
1956    }
1957
1958    #[test]
1959    fn plain_http_detection_decides_on_the_parsed_url_not_the_raw_prefix() {
1960        // Each of these is fetched by reqwest as http://idp.example.com/jwks.
1961        for spelling in [
1962            "http:/idp.example.com/jwks",
1963            "http:idp.example.com/jwks",
1964            "HTTP:\\\\idp.example.com\\jwks",
1965            " http://idp.example.com/jwks",
1966            "http:///idp.example.com/jwks",
1967            "\thttp://idp.example.com/jwks",
1968        ] {
1969            assert!(plain_http_non_loopback(spelling), "{spelling:?}");
1970        }
1971        // The same spellings to a loopback host, or over https, are not.
1972        for spelling in [
1973            "http:/localhost/jwks",
1974            "http:127.0.0.1:9000/jwks",
1975            "HTTP:\\\\[::1]\\jwks",
1976            " http://localhost/jwks",
1977            "https:/idp.example.com/jwks",
1978            "https:idp.example.com/jwks",
1979            "not a url",
1980        ] {
1981            assert!(!plain_http_non_loopback(spelling), "{spelling:?}");
1982        }
1983    }
1984
1985    #[test]
1986    fn accessors_expose_the_resource_and_where_its_metadata_lives() {
1987        let v = validator("http://127.0.0.1:1/jwks");
1988        assert_eq!(v.resource(), RESOURCE);
1989        assert_eq!(
1990            v.resource_metadata_url(),
1991            "https://kb.example.test/.well-known/oauth-protected-resource/mcp"
1992        );
1993        assert_eq!(
1994            v.metadata_path(),
1995            "/.well-known/oauth-protected-resource/mcp"
1996        );
1997        assert_eq!(v.config().issuer, ISSUER);
1998    }
1999
2000    #[test]
2001    fn a_hand_edited_unparseable_or_non_canonical_url_never_reaches_another_host() {
2002        // `resolve` refuses an unparseable issuer/resource, but
2003        // `ResolvedOAuthConfig`'s fields are public. Split on `://`, `https://`
2004        // and `https:///host` would put `.well-known` where the host goes.
2005        let host = |url: &str| {
2006            reqwest::Url::parse(url)
2007                .ok()
2008                .and_then(|u| u.host_str().map(str::to_owned))
2009        };
2010        for (value, expected_host) in [
2011            ("https://", None),
2012            ("https:///", None),
2013            ("https:", None),
2014            ("https:///idp.example.test/app/", Some("idp.example.test")),
2015        ] {
2016            let mut cfg = resolved_config("http://127.0.0.1:1/jwks");
2017            cfg.issuer = value.into();
2018            cfg.resource = value.into();
2019            let v = OAuthValidator::new(&cfg).unwrap();
2020            assert_eq!(
2021                host(v.resource_metadata_url()).as_deref(),
2022                expected_host,
2023                "{value:?}: {}",
2024                v.resource_metadata_url()
2025            );
2026            assert!(v.metadata_path().starts_with('/'), "{value:?}");
2027            for url in crate::jwks::discovery_urls(&cfg.issuer) {
2028                assert_eq!(host(&url).as_deref(), expected_host, "{value:?}: {url}");
2029            }
2030        }
2031        assert!(!is_canonical_url("https://"));
2032        assert!(!is_canonical_url("https:///idp.example.test/"));
2033        assert!(non_canonical_url("https://").is_none());
2034    }
2035
2036    #[test]
2037    fn a_backslash_only_in_the_path_is_canonical_for_splitting() {
2038        // The parser reads `\` in the path as `/`, which moves no host, so the
2039        // issuer still gets both discovery forms, on its own host.
2040        let issuer = "https://idp.example.test/app\\tenant/";
2041        assert!(is_canonical_url(issuer));
2042        assert_eq!(
2043            crate::jwks::discovery_urls(issuer),
2044            [
2045                "https://idp.example.test/app\\tenant/.well-known/openid-configuration",
2046                "https://idp.example.test/.well-known/oauth-authorization-server/app\\tenant",
2047            ]
2048        );
2049        assert_eq!(
2050            crate::challenge::resource_metadata_url("https://api.example.test/v1\\x"),
2051            "https://api.example.test/.well-known/oauth-protected-resource/v1\\x"
2052        );
2053        // In the authority it is not: the parser ends the host at the `\`.
2054        assert!(!is_canonical_url("https://idp.example.test\\app"));
2055        assert_eq!(
2056            crate::jwks::discovery_urls("https://idp.example.test\\app").len(),
2057            1
2058        );
2059    }
2060
2061    // ── the metadata document and the challenge headers ──────────────────────
2062
2063    #[test]
2064    fn metadata_document_has_the_rfc_9728_shape() {
2065        let v = validator("http://127.0.0.1:1/jwks");
2066        let doc = v.metadata();
2067        assert_eq!(doc["resource"], RESOURCE);
2068        // Byte-identical, trailing slash and all — a client matches this against
2069        // the `iss` of the tokens it receives.
2070        assert_eq!(doc["authorization_servers"][0], ISSUER);
2071        assert_eq!(doc["scopes_supported"][0], "mcp:read");
2072        assert_eq!(doc["scopes_supported"][1], "mcp:write");
2073        assert_eq!(doc["bearer_methods_supported"][0], "header");
2074        // `resource_name` names the application, so the crate sets none of its
2075        // own; it is published exactly when the application supplies one.
2076        assert!(doc.get("resource_name").is_none());
2077
2078        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2079        cfg.resource_name = Some("mcp-md-wiki knowledge base (MCP)".into());
2080        let v = validator_with(cfg);
2081        let doc = v.metadata();
2082        assert_eq!(doc["resource_name"], "mcp-md-wiki knowledge base (MCP)");
2083        assert_eq!(
2084            doc.to_string(),
2085            "{\"authorization_servers\":[\"https://authentik.example.test/application/o/example-app/\"],\
2086             \"bearer_methods_supported\":[\"header\"],\
2087             \"resource\":\"https://kb.example.test/mcp\",\
2088             \"resource_name\":\"mcp-md-wiki knowledge base (MCP)\",\
2089             \"scopes_supported\":[\"mcp:read\",\"mcp:write\"]}"
2090        );
2091    }
2092
2093    #[test]
2094    fn invalid_token_challenge_is_well_formed() {
2095        let v = validator("http://127.0.0.1:1/jwks");
2096        assert_eq!(
2097            v.invalid_token_challenge(),
2098            "Bearer error=\"invalid_token\", \
2099             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
2100             scope=\"mcp:read mcp:write\""
2101        );
2102    }
2103
2104    #[test]
2105    fn invalid_token_challenge_names_the_required_scopes_when_none_is_advertised() {
2106        // An explicitly empty `scopes_supported`: the 401 still tells a client
2107        // what to ask for, rather than leaving it to request nothing and hit 403.
2108        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2109        cfg.scopes_supported = Vec::new();
2110        let v = validator_with(cfg.clone());
2111        assert_eq!(
2112            v.invalid_token_challenge(),
2113            "Bearer error=\"invalid_token\", \
2114             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
2115             scope=\"mcp:read\""
2116        );
2117        // RFC 9728 §3.2: a parameter with zero values is omitted.
2118        assert!(
2119            v.metadata().get("scopes_supported").is_none(),
2120            "{}",
2121            v.metadata()
2122        );
2123
2124        // RFC 6749 §3.3: `scope` holds at least one scope-token, so with nothing
2125        // advertised AND nothing required the parameter is left out.
2126        cfg.required_scopes.clear();
2127        assert_eq!(
2128            validator_with(cfg).invalid_token_challenge(),
2129            "Bearer error=\"invalid_token\", \
2130             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
2131        );
2132    }
2133
2134    #[test]
2135    fn insufficient_scope_challenge_names_the_missing_scope_not_the_menu() {
2136        // With a single required scope this is byte-identical to what
2137        // mcp-md-wiki sent before mcp-md-wiki#308.
2138        let v = validator("http://127.0.0.1:1/jwks");
2139        assert_eq!(
2140            v.insufficient_scope_challenge(),
2141            "Bearer error=\"insufficient_scope\", scope=\"mcp:read\", \
2142             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
2143        );
2144    }
2145
2146    #[test]
2147    fn insufficient_scope_challenge_lists_every_required_scope_space_delimited() {
2148        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2149        cfg.required_scopes = vec!["mcp:read".into(), "mcp:write".into()];
2150        assert_eq!(
2151            validator_with(cfg).insufficient_scope_challenge(),
2152            "Bearer error=\"insufficient_scope\", scope=\"mcp:read mcp:write\", \
2153             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
2154        );
2155    }
2156
2157    const METADATA: &str = "https://kb.example.test/.well-known/oauth-protected-resource/mcp";
2158
2159    #[test]
2160    fn the_per_request_challenge_with_the_configured_scopes_is_the_fixed_one() {
2161        for required in [vec![], vec!["mcp:read"], vec!["mcp:read", "mcp:write"]] {
2162            let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2163            cfg.required_scopes = required.iter().map(|s| s.to_string()).collect();
2164            let v = validator_with(cfg);
2165            assert_eq!(
2166                v.insufficient_scope_challenge_for(&required, None),
2167                v.insufficient_scope_challenge(),
2168                "{required:?}"
2169            );
2170        }
2171    }
2172
2173    #[test]
2174    fn the_per_request_challenge_names_the_given_scopes_and_description() {
2175        let v = validator("http://127.0.0.1:1/jwks");
2176        assert_eq!(
2177            v.insufficient_scope_challenge_for(&["mcp:read", "mcp:write", "mcp:read"], None),
2178            format!(
2179                "Bearer error=\"insufficient_scope\", scope=\"mcp:read mcp:write\", \
2180                 resource_metadata=\"{METADATA}\""
2181            )
2182        );
2183        assert_eq!(
2184            v.insufficient_scope_challenge_for(&["files:write"], Some("  Write access needed ")),
2185            format!(
2186                "Bearer error=\"insufficient_scope\", scope=\"files:write\", \
2187                 resource_metadata=\"{METADATA}\", error_description=\"Write access needed\""
2188            )
2189        );
2190        // Nothing to name: no `scope`; a blank description: none either.
2191        assert_eq!(
2192            v.insufficient_scope_challenge_for(&[], Some(" \r\n ")),
2193            format!("Bearer error=\"insufficient_scope\", resource_metadata=\"{METADATA}\"")
2194        );
2195    }
2196
2197    #[test]
2198    fn the_per_request_challenge_cannot_be_injected_into() {
2199        let v = validator("http://127.0.0.1:1/jwks");
2200        let attempts = [
2201            "x\", scope=\"admin",
2202            "x\r\nSet-Cookie: session=1",
2203            "x\\\", error=\"invalid_token",
2204            "caf\u{e9} \u{0}\u{7f}\u{2028}",
2205        ];
2206        for description in attempts {
2207            let challenge = v.insufficient_scope_challenge_for(&["mcp:write"], Some(description));
2208            assert!(challenge::is_header_value(&challenge), "{challenge:?}");
2209            let (head, described) = challenge
2210                .split_once(", error_description=\"")
2211                .expect("a description is kept");
2212            // The description cannot close its quoted string early, carry an
2213            // escape, or add an attribute: exactly one `"` follows, the last.
2214            assert_eq!(described.matches('"').count(), 1, "{challenge:?}");
2215            assert!(described.ends_with('"'));
2216            assert!(!described.contains('\\'));
2217            assert_eq!(
2218                head,
2219                format!(
2220                    "Bearer error=\"insufficient_scope\", scope=\"mcp:write\", \
2221                     resource_metadata=\"{METADATA}\""
2222                )
2223            );
2224        }
2225        assert!(
2226            v.insufficient_scope_challenge_for(&["mcp:write"], Some("x\", scope=\"admin"))
2227                .ends_with("error_description=\"x , scope= admin\"")
2228        );
2229        // Scopes that are not scope-tokens are left out, never escaped in.
2230        assert_eq!(
2231            v.insufficient_scope_challenge_for(
2232                &[
2233                    "ok",
2234                    "",
2235                    "two words",
2236                    "q\"uote",
2237                    "back\\slash",
2238                    "nl\n",
2239                    "caf\u{e9}"
2240                ],
2241                None
2242            ),
2243            format!(
2244                "Bearer error=\"insufficient_scope\", scope=\"ok\", resource_metadata=\"{METADATA}\""
2245            )
2246        );
2247        // A long description is cut to the cap.
2248        let long = "a".repeat(1000);
2249        let challenge = v.insufficient_scope_challenge_for(&[], Some(&long));
2250        assert!(challenge.ends_with(&format!(
2251            "error_description=\"{}\"",
2252            "a".repeat(challenge::MAX_ERROR_DESCRIPTION_BYTES)
2253        )));
2254    }
2255
2256    #[test]
2257    fn a_fallback_validator_leaves_resource_metadata_out_of_the_per_request_challenge() {
2258        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2259        cfg.resource = "https://api.example.test/v1\r\nX-Injected: 1".into();
2260        let v = validator_with(cfg);
2261        assert!(v.challenge_fell_back());
2262        assert_eq!(
2263            v.insufficient_scope_challenge_for(&["mcp:write"], Some("d")),
2264            "Bearer error=\"insufficient_scope\", scope=\"mcp:write\", error_description=\"d\""
2265        );
2266    }
2267
2268    #[test]
2269    fn the_floor_comes_first_and_is_deduplicated() {
2270        let v = validator("http://127.0.0.1:1/jwks");
2271        assert_eq!(
2272            v.scopes_with_floor(&["mcp:write", "mcp:read", "mcp:write"]),
2273            ["mcp:read", "mcp:write"]
2274        );
2275    }
2276
2277    // ── token validation: the happy path and the original checks ─────────────
2278
2279    #[tokio::test]
2280    async fn a_well_formed_token_is_accepted_and_yields_its_scopes() {
2281        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2282        let v = validator(&jwks.url);
2283        let token = v.validate(&valid_token()).await.unwrap();
2284        assert_eq!(token.subject.as_deref(), Some("user-1"));
2285        assert_eq!(token.scopes, vec!["mcp:read", "mcp:write"]);
2286        assert!(token.has_scope("mcp:write"));
2287    }
2288
2289    #[tokio::test]
2290    async fn an_empty_credential_is_missing_not_invalid() {
2291        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2292        let v = validator(&jwks.url);
2293        assert_eq!(v.validate("").await.unwrap_err(), TokenRejection::Missing);
2294        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2295    }
2296
2297    #[tokio::test]
2298    async fn a_wrong_issuer_is_rejected() {
2299        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2300        let v = validator(&jwks.url);
2301        // Same issuer minus the trailing slash: the near-miss that actually happens
2302        // in practice, not an obviously foreign string.
2303        let token = mint(
2304            KEY_A_PEM,
2305            KID_A,
2306            &claims(serde_json::json!({
2307                "iss": ISSUER.trim_end_matches('/'), "scope": "mcp:read",
2308            })),
2309        );
2310        assert!(is_invalid(&v.validate(&token).await));
2311    }
2312
2313    #[tokio::test]
2314    async fn an_issuer_array_containing_the_right_issuer_is_rejected() {
2315        // jsonwebtoken on its own accepts this; `iss` is a single StringOrURI.
2316        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2317        let v = validator(&jwks.url);
2318        let token = mint(
2319            KEY_A_PEM,
2320            KID_A,
2321            &claims(serde_json::json!({
2322                "iss": ["https://evil.example.test/", ISSUER], "scope": "mcp:read",
2323            })),
2324        );
2325        assert!(is_invalid(&v.validate(&token).await));
2326    }
2327
2328    #[tokio::test]
2329    async fn a_missing_issuer_or_audience_is_rejected() {
2330        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2331        let v = validator(&jwks.url);
2332        // jsonwebtoken only checks iss/aud when the claim is present, so omitting
2333        // them entirely is the way a token would sneak past a validator that had
2334        // not set `required_spec_claims`.
2335        for claims in [
2336            serde_json::json!({"aud": AUDIENCE, "exp": now() + 3600, "scope": "mcp:read"}),
2337            serde_json::json!({"iss": ISSUER, "exp": now() + 3600, "scope": "mcp:read"}),
2338        ] {
2339            let token = mint(KEY_A_PEM, KID_A, &claims);
2340            assert!(is_invalid(&v.validate(&token).await));
2341        }
2342    }
2343
2344    // ── audience ─────────────────────────────────────────────────────────────
2345
2346    #[tokio::test]
2347    async fn aud_is_accepted_as_a_string_and_as_an_array() {
2348        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2349        let v = validator(&jwks.url);
2350        for aud in [
2351            serde_json::json!(AUDIENCE),
2352            serde_json::json!(["some-other-client", AUDIENCE]),
2353        ] {
2354            let token = mint(
2355                KEY_A_PEM,
2356                KID_A,
2357                &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
2358            );
2359            assert!(
2360                v.validate(&token).await.is_ok(),
2361                "aud must be accepted in both RFC 7519 §4.1.3 shapes"
2362            );
2363        }
2364    }
2365
2366    #[tokio::test]
2367    async fn a_wrong_empty_or_malformed_audience_is_rejected() {
2368        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2369        let v = validator(&jwks.url);
2370        for aud in [
2371            serde_json::json!("some-other-client"),
2372            serde_json::json!([]),
2373            serde_json::json!(["some-other-client"]),
2374            serde_json::json!(42),
2375            serde_json::json!([AUDIENCE, 42]),
2376            serde_json::json!(""),
2377        ] {
2378            let token = mint(
2379                KEY_A_PEM,
2380                KID_A,
2381                &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
2382            );
2383            assert!(
2384                is_invalid(&v.validate(&token).await),
2385                "aud {aud} must never be accepted"
2386            );
2387        }
2388    }
2389
2390    #[tokio::test]
2391    async fn every_configured_audience_is_accepted_and_nothing_else() {
2392        // `audience` (single key) + `audiences` (list) are unioned: the migration
2393        // from client_id to resource-URL audience can run with both.
2394        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2395        let mut cfg = oauth_config(&jwks.url);
2396        cfg.audiences = vec![RESOURCE.to_string()];
2397        let v = validator_with(cfg);
2398        for aud in [AUDIENCE, RESOURCE] {
2399            let token = mint(
2400                KEY_A_PEM,
2401                KID_A,
2402                &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
2403            );
2404            assert!(v.validate(&token).await.is_ok(), "{aud} is configured");
2405        }
2406        let token = mint(
2407            KEY_A_PEM,
2408            KID_A,
2409            &claims(
2410                serde_json::json!({"aud": "https://other.example.test/mcp", "scope": "mcp:read"}),
2411            ),
2412        );
2413        assert!(is_invalid(&v.validate(&token).await));
2414    }
2415
2416    // ── expiry, not-before and clock skew ────────────────────────────────────
2417
2418    #[tokio::test]
2419    async fn an_expired_token_is_rejected_beyond_the_leeway() {
2420        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2421        let v = validator(&jwks.url);
2422        let token = mint(
2423            KEY_A_PEM,
2424            KID_A,
2425            &claims(serde_json::json!({
2426                "exp": now() - (crate::DEFAULT_LEEWAY_SECS + 60), "scope": "mcp:read",
2427            })),
2428        );
2429        assert!(is_invalid(&v.validate(&token).await));
2430    }
2431
2432    #[tokio::test]
2433    async fn skew_within_the_leeway_is_tolerated_and_zero_leeway_is_strict() {
2434        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2435        let just_expired = mint(
2436            KEY_A_PEM,
2437            KID_A,
2438            &claims(serde_json::json!({"exp": now() - 10, "scope": "mcp:read"})),
2439        );
2440        let not_yet_valid = mint(
2441            KEY_A_PEM,
2442            KID_A,
2443            &claims(serde_json::json!({"nbf": now() + 10, "scope": "mcp:read"})),
2444        );
2445
2446        let lenient = validator(&jwks.url);
2447        assert!(lenient.validate(&just_expired).await.is_ok());
2448        assert!(lenient.validate(&not_yet_valid).await.is_ok());
2449
2450        let mut cfg = oauth_config(&jwks.url);
2451        cfg.leeway_secs = 0;
2452        let strict = validator_with(cfg);
2453        assert!(is_invalid(&strict.validate(&just_expired).await));
2454        assert!(is_invalid(&strict.validate(&not_yet_valid).await));
2455    }
2456
2457    #[tokio::test]
2458    async fn a_token_used_before_nbf_is_rejected_beyond_the_leeway() {
2459        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2460        let v = validator(&jwks.url);
2461        let token = mint(
2462            KEY_A_PEM,
2463            KID_A,
2464            &claims(serde_json::json!({
2465                "nbf": now() + crate::DEFAULT_LEEWAY_SECS + 120, "scope": "mcp:read",
2466            })),
2467        );
2468        assert!(is_invalid(&v.validate(&token).await));
2469    }
2470
2471    #[tokio::test]
2472    async fn a_token_signed_by_the_wrong_key_is_rejected() {
2473        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2474        let v = validator(&jwks.url);
2475        // Signed by B but LABELLED as A, so the lookup succeeds and the failure is
2476        // genuinely a signature failure rather than an unknown-kid failure.
2477        let token = mint(
2478            KEY_B_PEM,
2479            KID_A,
2480            &claims(serde_json::json!({"scope": "mcp:read"})),
2481        );
2482        assert!(is_invalid(&v.validate(&token).await));
2483    }
2484
2485    // ── scope extraction: every shape ────────────────────────────────────────
2486
2487    async fn scopes_of(extra: serde_json::Value) -> Result<AuthorizedToken, TokenRejection> {
2488        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2489        let v = validator(&jwks.url);
2490        v.validate(&mint(KEY_A_PEM, KID_A, &claims(extra))).await
2491    }
2492
2493    #[tokio::test]
2494    async fn scope_as_a_space_delimited_string_is_read() {
2495        let t = scopes_of(serde_json::json!({"scope": "openid  mcp:read\tmcp:write"}))
2496            .await
2497            .unwrap();
2498        assert_eq!(t.scopes, ["openid", "mcp:read", "mcp:write"]);
2499    }
2500
2501    #[tokio::test]
2502    async fn scp_as_an_array_is_read() {
2503        // Authelia's shape — the incompatibility the `scp` fallback fixes.
2504        let t = scopes_of(serde_json::json!({"scp": ["mcp:read", "mcp:write"]}))
2505            .await
2506            .unwrap();
2507        assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
2508    }
2509
2510    #[tokio::test]
2511    async fn scp_as_a_space_delimited_string_is_read() {
2512        // Entra ID's (and Ory Hydra's `scope_claim: string`) shape.
2513        let t = scopes_of(serde_json::json!({"scp": "mcp:read mcp:write"}))
2514            .await
2515            .unwrap();
2516        assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
2517    }
2518
2519    #[tokio::test]
2520    async fn scope_and_scp_together_are_unioned_without_duplicates() {
2521        let t = scopes_of(serde_json::json!({
2522            "scope": "openid mcp:read", "scp": ["mcp:read", "mcp:write"],
2523        }))
2524        .await
2525        .unwrap();
2526        assert_eq!(t.scopes, ["openid", "mcp:read", "mcp:write"]);
2527    }
2528
2529    #[tokio::test]
2530    async fn the_required_scope_in_scp_alone_satisfies_the_check() {
2531        let t = scopes_of(serde_json::json!({"scope": "openid", "scp": ["mcp:read"]}))
2532            .await
2533            .unwrap();
2534        assert!(t.has_scope("mcp:read"));
2535    }
2536
2537    #[tokio::test]
2538    async fn neither_claim_or_non_string_shapes_are_insufficient_not_invalid() {
2539        for extra in [
2540            serde_json::json!({}),
2541            serde_json::json!({"scope": ""}),
2542            serde_json::json!({"scope": "openid profile"}),
2543            serde_json::json!({"scp": []}),
2544            serde_json::json!({"scp": [1, {"mcp:read": true}]}),
2545            serde_json::json!({"scope": {"mcp:read": true}}),
2546            // Scope matching is exact and case-sensitive (RFC 6749 §3.3).
2547            serde_json::json!({"scope": "MCP:READ mcp:read:extra"}),
2548        ] {
2549            assert_eq!(
2550                scopes_of(extra.clone()).await.unwrap_err(),
2551                TokenRejection::InsufficientScope,
2552                "{extra} — the token itself is fine; conflating this with \
2553                 invalid_token sends the client round the authorization flow to the \
2554                 same refusal"
2555            );
2556        }
2557    }
2558
2559    #[tokio::test]
2560    async fn only_the_configured_scope_claims_are_read() {
2561        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2562        let mut cfg = oauth_config(&jwks.url);
2563        cfg.scope_claims = vec!["scope".to_string()];
2564        let v = validator_with(cfg);
2565        let token = mint(
2566            KEY_A_PEM,
2567            KID_A,
2568            &claims(serde_json::json!({"scp": ["mcp:read"]})),
2569        );
2570        assert_eq!(
2571            v.validate(&token).await.unwrap_err(),
2572            TokenRejection::InsufficientScope
2573        );
2574    }
2575
2576    // ── required scopes: all-of ──────────────────────────────────────────────
2577
2578    #[tokio::test]
2579    async fn every_required_scope_must_be_present() {
2580        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2581        let mut cfg = oauth_config(&jwks.url);
2582        cfg.required_scopes = vec!["mcp:read".into(), "mcp:write".into()];
2583        let v = validator_with(cfg);
2584        for (scope, ok) in [
2585            ("mcp:read", false),
2586            ("mcp:write", false),
2587            ("openid", false),
2588            ("mcp:read mcp:write", true),
2589            ("mcp:write openid mcp:read", true),
2590        ] {
2591            let token = mint(
2592                KEY_A_PEM,
2593                KID_A,
2594                &claims(serde_json::json!({ "scope": scope })),
2595            );
2596            let result = v.validate(&token).await;
2597            if ok {
2598                assert!(result.is_ok(), "{scope:?} carries every required scope");
2599            } else {
2600                assert_eq!(
2601                    result.unwrap_err(),
2602                    TokenRejection::InsufficientScope,
2603                    "{scope:?} lacks one"
2604                );
2605            }
2606        }
2607    }
2608
2609    #[tokio::test]
2610    async fn an_empty_required_scope_set_passes_the_scope_check() {
2611        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2612        let mut cfg = oauth_config(&jwks.url);
2613        cfg.required_scopes.clear();
2614        let v = validator_with(cfg);
2615        // No scope claim at all: still a valid token, just an unscoped one.
2616        let t = v
2617            .validate(&mint(KEY_A_PEM, KID_A, &claims(serde_json::json!({}))))
2618            .await
2619            .unwrap();
2620        assert!(t.scopes.is_empty());
2621        // Every other check still applies.
2622        let expired = mint(
2623            KEY_A_PEM,
2624            KID_A,
2625            &claims(serde_json::json!({"exp": now() - 3600})),
2626        );
2627        assert!(is_invalid(&v.validate(&expired).await));
2628    }
2629
2630    // ── principal ────────────────────────────────────────────────────────────
2631
2632    #[tokio::test]
2633    async fn the_principal_is_the_first_present_claim_of_the_chain() {
2634        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2635        let mut cfg = oauth_config(&jwks.url);
2636        cfg.principal_claims = vec!["preferred_username".into(), "email".into(), "sub".into()];
2637        let v = validator_with(cfg);
2638        for (extra, expected) in [
2639            (
2640                serde_json::json!({"preferred_username": "alice", "email": "a@example.com"}),
2641                "alice",
2642            ),
2643            (
2644                serde_json::json!({"preferred_username": "", "email": "a@example.com"}),
2645                "a@example.com",
2646            ),
2647            (serde_json::json!({"preferred_username": 7}), "user-1"),
2648        ] {
2649            let mut c = claims(extra);
2650            c["scope"] = "mcp:read".into();
2651            let t = v.validate(&mint(KEY_A_PEM, KID_A, &c)).await.unwrap();
2652            assert_eq!(t.principal.as_deref(), Some(expected));
2653        }
2654    }
2655
2656    /// `subject` and `principal` are identity values a handler may key on, so
2657    /// two signed values sharing a long prefix must stay distinct: truncation
2658    /// happens at log call sites only.
2659    #[tokio::test]
2660    async fn long_subjects_and_principals_are_kept_verbatim() {
2661        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2662        let mut cfg = oauth_config(&jwks.url);
2663        cfg.principal_claims = vec!["email".into()];
2664        let v = validator_with(cfg);
2665        let prefix = "u".repeat(200);
2666        let mut seen = Vec::new();
2667        for suffix in ["-a", "-b"] {
2668            let sub = format!("{prefix}{suffix}");
2669            let email = format!("{prefix}{suffix}@example.com");
2670            let c = claims(serde_json::json!({"sub": sub, "email": email, "scope": "mcp:read"}));
2671            let t = v.validate(&mint(KEY_A_PEM, KID_A, &c)).await.unwrap();
2672            assert_eq!(t.subject.as_deref(), Some(sub.as_str()));
2673            assert_eq!(t.principal.as_deref(), Some(email.as_str()));
2674            seen.push(t.subject);
2675        }
2676        assert_ne!(seen[0], seen[1]);
2677    }
2678
2679    // ── algorithm and key confusion ──────────────────────────────────────────
2680
2681    #[tokio::test]
2682    async fn alg_none_is_rejected_before_any_jwks_fetch() {
2683        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2684        let v = validator(&jwks.url);
2685        // Hand-assembled (no crate will sign `none`): base64url of
2686        // `{"alg":"none","typ":"JWT"}` / `{"alg":"None"}`, a payload with a
2687        // plausible claim set, and an empty signature.
2688        let payload = "eyJpc3MiOiJ4IiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJzY29wZSI6Im1jcDpyZWFkIiwiZXhwIjo5OTk5OTk5OTk5fQ";
2689        for header in ["eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0", "eyJhbGciOiJOb25lIn0"] {
2690            let token = format!("{header}.{payload}.");
2691            assert!(is_invalid(&v.validate(&token).await), "{header}");
2692        }
2693        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2694    }
2695
2696    #[tokio::test]
2697    async fn hs256_signed_with_the_public_key_is_rejected_before_any_jwks_fetch() {
2698        // The classic confusion: an attacker HMACs a token with the server's
2699        // PUBLIC key bytes and hopes the verifier treats them as the HMAC secret.
2700        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2701        let v = validator(&jwks.url);
2702        let published = jwks_body();
2703        for secret in [N_A.as_bytes(), published.as_bytes()] {
2704            let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
2705            header.kid = Some(KID_A.to_string());
2706            let token = jsonwebtoken::encode(
2707                &header,
2708                &claims(serde_json::json!({"scope": "mcp:read"})),
2709                &jsonwebtoken::EncodingKey::from_secret(secret),
2710            )
2711            .unwrap();
2712            assert!(is_invalid(&v.validate(&token).await));
2713        }
2714        assert_eq!(
2715            jwks.hits.load(Ordering::SeqCst),
2716            0,
2717            "a junk algorithm must not be able to schedule IdP traffic"
2718        );
2719    }
2720
2721    #[tokio::test]
2722    async fn a_symmetric_key_in_the_jwks_is_never_used() {
2723        // Even a key set that (wrongly) publishes an `oct` key cannot make HMAC
2724        // verification reachable: the key is dropped at load, and HS* is not
2725        // configurable anyway.
2726        let body = jwks_of(&[serde_json::json!({"kty": "oct", "kid": KID_A, "k": "c2VjcmV0"})]);
2727        let jwks = spawn_jwks_server("200 OK", body).await;
2728        let v = validator(&jwks.url);
2729        assert!(is_invalid(&v.validate(&valid_token()).await));
2730    }
2731
2732    #[tokio::test]
2733    async fn a_token_alg_the_named_key_cannot_produce_is_rejected() {
2734        // Header says ES256 but names the RSA key: the key's type pins it to
2735        // RS*/PS*, so there is no key to verify with. Also the reverse.
2736        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2737        let v = validator(&jwks.url);
2738        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2739        let es_labelled_rsa = mint_with(Algorithm::ES256, Some(KID_A), None, &c.clone());
2740        assert!(is_invalid(&v.validate(&es_labelled_rsa).await));
2741        let rs_labelled_ec = mint_with(Algorithm::RS256, Some(KID_EC), None, &c.clone());
2742        assert!(is_invalid(&v.validate(&rs_labelled_ec).await));
2743        // KID_A declares `alg: RS256`, so it must refuse PS256 even though an RSA
2744        // key could technically verify it.
2745        let ps_on_rs_only_key = mint_with(Algorithm::PS256, Some(KID_A), None, &c);
2746        assert!(is_invalid(&v.validate(&ps_on_rs_only_key).await));
2747    }
2748
2749    #[tokio::test]
2750    async fn es256_ps256_and_eddsa_tokens_are_accepted() {
2751        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2752        let v = validator(&jwks.url);
2753        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2754        for (alg, kid) in [
2755            (Algorithm::ES256, KID_EC),
2756            (Algorithm::PS256, "test-key-a-pss"),
2757            (Algorithm::RS384, "test-key-a-pss"),
2758            (Algorithm::EdDSA, KID_ED),
2759            (Algorithm::RS256, KID_A),
2760        ] {
2761            let token = mint_with(alg, Some(kid), Some("at+jwt"), &c.clone());
2762            assert!(v.validate(&token).await.is_ok(), "{alg:?} must verify");
2763        }
2764    }
2765
2766    #[tokio::test]
2767    async fn an_algorithm_outside_the_allowlist_is_rejected_before_any_jwks_fetch() {
2768        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2769        let mut cfg = oauth_config(&jwks.url);
2770        cfg.algorithms = vec![Algorithm::RS256];
2771        let v = validator_with(cfg);
2772        let token = mint_with(
2773            Algorithm::ES256,
2774            Some(KID_EC),
2775            None,
2776            &claims(serde_json::json!({"scope": "mcp:read"})),
2777        );
2778        assert!(is_invalid(&v.validate(&token).await));
2779        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2780    }
2781
2782    #[tokio::test]
2783    async fn rejection_reasons_name_settings_per_key_naming() {
2784        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2785        let token = mint_with(
2786            Algorithm::ES256,
2787            Some(KID_EC),
2788            None,
2789            &claims(serde_json::json!({"scope": "mcp:read"})),
2790        );
2791        for (naming, expected) in [
2792            (
2793                KeyNamingBuf::Dotted("mcp.oauth".into()),
2794                "token algorithm ES256 is not in mcp.oauth.algorithms",
2795            ),
2796            (
2797                KeyNamingBuf::Env("APP_OAUTH_".into()),
2798                "token algorithm ES256 is not in APP_OAUTH_ALGORITHMS",
2799            ),
2800        ] {
2801            let mut cfg = oauth_config(&jwks.url);
2802            cfg.algorithms = vec![Algorithm::RS256];
2803            cfg.key_naming = naming;
2804            crate::token::assert_invalid(
2805                Err::<(), _>(validator_with(cfg).validate(&token).await.unwrap_err()),
2806                InvalidTokenKind::AlgorithmNotAllowed,
2807                expected,
2808                "",
2809            );
2810        }
2811    }
2812
2813    // ── typ ──────────────────────────────────────────────────────────────────
2814
2815    #[tokio::test]
2816    async fn typ_access_token_types_pass_and_other_jwt_types_fail() {
2817        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2818        let v = validator(&jwks.url);
2819        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2820        for typ in [
2821            None,
2822            Some("JWT"),
2823            Some("jwt"),
2824            Some("at+jwt"),
2825            Some("AT+JWT"),
2826            Some("application/at+jwt"),
2827        ] {
2828            let token = mint_with(Algorithm::RS256, Some(KID_A), typ, &c.clone());
2829            assert!(v.validate(&token).await.is_ok(), "typ {typ:?} must pass");
2830        }
2831        for typ in ["dpop+jwt", "logout+jwt", "secevent+jwt", "JOSE"] {
2832            let token = mint_with(Algorithm::RS256, Some(KID_A), Some(typ), &c.clone());
2833            assert!(is_invalid(&v.validate(&token).await), "typ {typ} must fail");
2834        }
2835    }
2836
2837    #[tokio::test]
2838    async fn require_at_jwt_refuses_plain_jwt_and_a_missing_typ() {
2839        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2840        let mut cfg = oauth_config(&jwks.url);
2841        cfg.require_at_jwt = true;
2842        let v = validator_with(cfg);
2843        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2844        for typ in [None, Some("JWT")] {
2845            let token = mint_with(Algorithm::RS256, Some(KID_A), typ, &c.clone());
2846            assert!(is_invalid(&v.validate(&token).await), "typ {typ:?}");
2847        }
2848        let token = mint_with(Algorithm::RS256, Some(KID_A), Some("at+jwt"), &c);
2849        assert!(v.validate(&token).await.is_ok());
2850    }
2851
2852    // ── credential shape ─────────────────────────────────────────────────────
2853
2854    #[tokio::test]
2855    async fn garbage_opaque_and_oversized_credentials_are_rejected_without_a_fetch() {
2856        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2857        let v = validator(&jwks.url);
2858        let oversized = format!("{}.{}.{}", "a".repeat(MAX_TOKEN_BYTES), "b", "c");
2859        for junk in [
2860            "not-a-jwt",
2861            "a.b.c",
2862            "a.b",
2863            // An Authelia-style opaque access token.
2864            "authelia_at_Xy9vQ3c2bG9uZ3JhbmRvbXN0cmluZw.abc",
2865            oversized.as_str(),
2866        ] {
2867            assert!(is_invalid(&v.validate(junk).await), "{junk:.40}");
2868        }
2869        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2870    }
2871
2872    /// Unpadded base64url, for hand-built token headers.
2873    fn b64url(bytes: &[u8]) -> String {
2874        const ALPHABET: &[u8; 64] =
2875            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
2876        let mut out = String::new();
2877        for chunk in bytes.chunks(3) {
2878            let n = chunk
2879                .iter()
2880                .enumerate()
2881                .fold(0u32, |acc, (i, &b)| acc | (u32::from(b) << (16 - 8 * i)));
2882            for i in 0..=chunk.len() {
2883                out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
2884            }
2885        }
2886        out
2887    }
2888
2889    /// An unknown `alg` string is echoed verbatim by the header parser's error;
2890    /// the rejection reason (which reaches a warn-level log line) must not carry
2891    /// all of it.
2892    #[tokio::test]
2893    async fn a_malformed_header_reason_is_truncated() {
2894        let v = validator("http://127.0.0.1:1/jwks");
2895        let header = format!(r#"{{"alg":"{}","typ":"JWT"}}"#, "A".repeat(8 * 1024));
2896        let token = format!("{}.e30.sig", b64url(header.as_bytes()));
2897        match v.validate(&token).await {
2898            Err(TokenRejection::Invalid(reason)) => {
2899                assert!(
2900                    reason.starts_with("malformed token header: "),
2901                    "{reason:.80}"
2902                );
2903                assert!(
2904                    reason.chars().count() <= "malformed token header: ".len() + 129,
2905                    "{} chars",
2906                    reason.chars().count()
2907                );
2908            }
2909            other => panic!("expected Invalid, got {other:?}"),
2910        }
2911    }
2912
2913    /// A token signed with [`KEY_A_PEM`] whose protected header is exactly
2914    /// `header` — for header members jsonwebtoken's `Header` cannot express.
2915    fn mint_raw_header(header: serde_json::Value, claims: serde_json::Value) -> String {
2916        let input = format!(
2917            "{}.{}",
2918            b64url(header.to_string().as_bytes()),
2919            b64url(claims.to_string().as_bytes())
2920        );
2921        let key = jsonwebtoken::EncodingKey::from_rsa_pem(KEY_A_PEM.as_bytes()).unwrap();
2922        let signature =
2923            jsonwebtoken::crypto::sign(input.as_bytes(), &key, jsonwebtoken::Algorithm::RS256)
2924                .unwrap();
2925        format!("{input}.{signature}")
2926    }
2927
2928    /// RFC 7515 §4.1.11: a `crit` header naming an extension the recipient does
2929    /// not understand makes the JWS invalid. This crate understands none, so
2930    /// every `crit` — unknown, empty or malformed — is refused, before any key
2931    /// is fetched.
2932    #[tokio::test]
2933    async fn a_crit_header_is_refused_before_any_jwks_fetch() {
2934        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2935        let v = validator(&jwks.url);
2936        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2937        for crit in [
2938            serde_json::json!(["urn:example:must-understand"]),
2939            serde_json::json!([]),
2940            serde_json::json!("not-an-array"),
2941        ] {
2942            let token = mint_raw_header(
2943                serde_json::json!({
2944                    "alg": "RS256", "kid": KID_A, "crit": crit,
2945                    "urn:example:must-understand": true,
2946                }),
2947                c.clone(),
2948            );
2949            crate::token::assert_invalid(
2950                v.validate(&token).await,
2951                InvalidTokenKind::CriticalHeader,
2952                "token header lists critical extensions (crit), none of which this \
2953                     server supports",
2954                &format!("crit {crit}"),
2955            );
2956        }
2957        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2958        // The same hand-built header without `crit` is accepted, so it is the
2959        // `crit` being refused, not the construction.
2960        let token = mint_raw_header(serde_json::json!({"alg": "RS256", "kid": KID_A}), c);
2961        assert!(v.validate(&token).await.is_ok());
2962    }
2963
2964    /// jsonwebtoken reads `"kid": null` as no `kid`, which would send the
2965    /// token down the kid-less single-key fallback; a present `kid` must be a
2966    /// string, so the header is malformed — refused before any key fetch.
2967    #[tokio::test]
2968    async fn a_null_kid_is_a_malformed_header_not_an_absent_one() {
2969        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2970        let v = validator(&jwks.url);
2971        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2972        let token = mint_raw_header(serde_json::json!({"alg": "RS256", "kid": null}), c.clone());
2973        crate::token::assert_invalid(
2974            v.validate(&token).await,
2975            InvalidTokenKind::MalformedHeader,
2976            "malformed token header: kid is present but not a string",
2977            "kid null",
2978        );
2979        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2980        // With no `kid` member at all, the single-key fallback still applies.
2981        let token = mint_raw_header(serde_json::json!({"alg": "RS256"}), c);
2982        assert!(v.validate(&token).await.is_ok());
2983    }
2984
2985    /// A signed payload that is a JSON object but whose `exp`/`nbf` is a
2986    /// shape jsonwebtoken's own typed claim struct cannot read (an array)
2987    /// fails inside `decode` with a JSON error: that is a malformed claim,
2988    /// not a malformed token. A payload that is no JSON object at all is.
2989    #[tokio::test]
2990    async fn an_unreadable_registered_claim_is_malformed_claim_not_malformed_token() {
2991        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2992        let v = validator(&jwks.url);
2993        let later = now() + 3600;
2994        for extra in [
2995            serde_json::json!({"nbf": [later]}),
2996            serde_json::json!({"exp": [later]}),
2997            serde_json::json!({"nbf": {"at": later}}),
2998        ] {
2999            let mut body = claims(serde_json::json!({"scope": "mcp:read"}));
3000            for (k, val) in extra.as_object().unwrap() {
3001                body[k] = val.clone();
3002            }
3003            let result = v.validate(&mint(KEY_A_PEM, KID_A, &body)).await;
3004            match result {
3005                Err(TokenRejection::Invalid(invalid)) => {
3006                    assert_eq!(invalid.kind(), InvalidTokenKind::MalformedClaim, "{extra}")
3007                }
3008                other => panic!("{extra}: {other:?}"),
3009            }
3010        }
3011        let token = mint_raw_header(
3012            serde_json::json!({"alg": "RS256", "kid": KID_A}),
3013            serde_json::json!([1, 2]),
3014        );
3015        match v.validate(&token).await {
3016            Err(TokenRejection::Invalid(invalid)) => {
3017                assert_eq!(invalid.kind(), InvalidTokenKind::MalformedToken)
3018            }
3019            other => panic!("{other:?}"),
3020        }
3021    }
3022
3023    /// RFC 7519 §4.1.5: `nbf` is a NumericDate. jsonwebtoken silently skips one
3024    /// it cannot read as a number, which would make a not-yet-valid token valid.
3025    #[tokio::test]
3026    async fn an_nbf_that_is_not_a_numeric_date_is_refused() {
3027        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3028        let v = validator(&jwks.url);
3029        let later = now() + 365 * 24 * 3600;
3030        for nbf in [
3031            serde_json::json!(later.to_string()),
3032            serde_json::json!("later"),
3033            serde_json::json!(1e30),
3034            serde_json::json!(-5),
3035            serde_json::json!(null),
3036        ] {
3037            let token = mint(
3038                KEY_A_PEM,
3039                KID_A,
3040                &claims(serde_json::json!({"nbf": nbf, "scope": "mcp:read"})),
3041            );
3042            crate::token::assert_invalid(
3043                v.validate(&token).await,
3044                InvalidTokenKind::MalformedClaim,
3045                "token nbf is not a NumericDate (a non-negative number of seconds)",
3046                &format!("nbf {nbf}"),
3047            );
3048        }
3049        // An array fails jsonwebtoken's own claim parsing: refused either way.
3050        let token = mint(
3051            KEY_A_PEM,
3052            KID_A,
3053            &claims(serde_json::json!({"nbf": [later], "scope": "mcp:read"})),
3054        );
3055        assert!(is_invalid(&v.validate(&token).await));
3056        // Numbers, integral or not, are checked normally.
3057        let past = mint(
3058            KEY_A_PEM,
3059            KID_A,
3060            &claims(serde_json::json!({"nbf": now() as f64 - 10.5, "scope": "mcp:read"})),
3061        );
3062        assert!(v.validate(&past).await.is_ok());
3063        let future = mint(
3064            KEY_A_PEM,
3065            KID_A,
3066            &claims(serde_json::json!({"nbf": later, "scope": "mcp:read"})),
3067        );
3068        match v.validate(&future).await {
3069            Err(TokenRejection::Invalid(reason)) => {
3070                assert!(reason.contains("ImmatureSignature"), "{reason}");
3071            }
3072            other => panic!("expected Invalid, got {other:?}"),
3073        }
3074    }
3075
3076    /// RFC 9449 §7.2 / RFC 8705 §3: a sender-constrained token must not be
3077    /// accepted as a bearer token by a server that cannot check the binding.
3078    #[tokio::test]
3079    async fn a_sender_constrained_token_is_refused() {
3080        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3081        let v = validator(&jwks.url);
3082        for cnf in [
3083            serde_json::json!({"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"}),
3084            serde_json::json!({"x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"}),
3085            serde_json::json!(null),
3086        ] {
3087            let token = mint_with(
3088                crate::Algorithm::RS256,
3089                Some(KID_A),
3090                Some("at+jwt"),
3091                &claims(serde_json::json!({"cnf": cnf, "scope": "mcp:read"})),
3092            );
3093            crate::token::assert_invalid(
3094                v.validate(&token).await,
3095                InvalidTokenKind::SenderConstrained,
3096                "token is sender-constrained (cnf); this server accepts bearer tokens only",
3097                &format!("cnf {cnf}"),
3098            );
3099        }
3100    }
3101
3102    // ── JWKS fetching, rotation and rate limiting ────────────────────────────
3103
3104    #[tokio::test]
3105    async fn the_jwks_is_fetched_once_and_cached() {
3106        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3107        let v = validator(&jwks.url);
3108        for _ in 0..3 {
3109            v.validate(&valid_token()).await.unwrap();
3110        }
3111        assert_eq!(
3112            jwks.hits.load(Ordering::SeqCst),
3113            1,
3114            "a cached key must not be re-fetched per request"
3115        );
3116    }
3117
3118    #[tokio::test]
3119    async fn an_unknown_kid_does_not_refetch_during_the_cooldown() {
3120        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3121        let v = validator(&jwks.url); // real 60s cooldown
3122        // First call populates the cache (one fetch); the unknown kid is then NOT
3123        // worth a second fetch, because we just fetched.
3124        let token = mint(
3125            KEY_A_PEM,
3126            "rotated-key",
3127            &claims(serde_json::json!({"scope": "mcp:read"})),
3128        );
3129        for _ in 0..5 {
3130            assert!(is_invalid(&v.validate(&token).await));
3131        }
3132        assert_eq!(
3133            jwks.hits.load(Ordering::SeqCst),
3134            1,
3135            "kid is attacker-controlled — five junk tokens must not mean five IdP hits"
3136        );
3137    }
3138
3139    #[tokio::test]
3140    async fn concurrent_unknown_kids_cost_one_fetch() {
3141        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3142        let v = Arc::new(validator(&jwks.url));
3143        let mut tasks = Vec::new();
3144        for i in 0..20 {
3145            let v = Arc::clone(&v);
3146            tasks.push(tokio::spawn(async move {
3147                let token = mint(
3148                    KEY_A_PEM,
3149                    &format!("junk-{i}"),
3150                    &claims(serde_json::json!({"scope": "mcp:read"})),
3151                );
3152                v.validate(&token).await
3153            }));
3154        }
3155        for t in tasks {
3156            assert!(is_invalid(&t.await.unwrap()));
3157        }
3158        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
3159    }
3160
3161    #[tokio::test]
3162    async fn an_unknown_kid_refetches_once_the_cooldown_has_passed() {
3163        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3164        let v = validator_no_cooldown(&jwks.url);
3165        let token = mint(
3166            KEY_A_PEM,
3167            "rotated-key",
3168            &claims(serde_json::json!({"scope": "mcp:read"})),
3169        );
3170        assert!(is_invalid(&v.validate(&token).await));
3171        assert!(is_invalid(&v.validate(&token).await));
3172        assert_eq!(
3173            jwks.hits.load(Ordering::SeqCst),
3174            2,
3175            "with the cooldown elapsed, an unknown kid must trigger a refresh — this \
3176             is how a rotated signing key is picked up without a restart"
3177        );
3178    }
3179
3180    #[tokio::test]
3181    async fn a_rotated_key_is_picked_up_and_a_withdrawn_key_is_dropped() {
3182        let jwks = spawn_http_server(HashMap::new(), None).await;
3183        let set = |body: String| {
3184            jwks.routes
3185                .lock()
3186                .unwrap()
3187                .insert("/jwks".to_string(), ("200 OK", body));
3188        };
3189        set(jwks_body());
3190        let v = validator_no_cooldown(&jwks.url);
3191        let c = claims(serde_json::json!({"scope": "mcp:read"}));
3192        let old = mint(KEY_A_PEM, KID_A, &c.clone());
3193        let new = mint_with(Algorithm::ES256, Some(KID_EC), None, &c);
3194
3195        assert!(v.validate(&old).await.is_ok());
3196        // The AS publishes the new key alongside the old one: the unknown kid
3197        // triggers a refetch and both verify.
3198        set(jwks_of(&[jwk_rsa_a(), jwk_ec()]));
3199        assert!(v.validate(&new).await.is_ok());
3200        assert!(v.validate(&old).await.is_ok());
3201        // The AS withdraws the old key; the next refresh (the background task's
3202        // job) must stop trusting it.
3203        set(jwks_of(&[jwk_ec()]));
3204        assert_eq!(v.refresh_now().await.unwrap(), 1);
3205        assert!(is_invalid(&v.validate(&old).await));
3206        assert!(v.validate(&new).await.is_ok());
3207    }
3208
3209    #[tokio::test]
3210    async fn a_slow_refresh_does_not_stall_requests_whose_key_is_cached() {
3211        // Holding the key lock across the fetch would, with tokio's
3212        // writer-preferring RwLock, park every request behind a slow IdP.
3213        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3214        let v = Arc::new(validator_no_cooldown(&jwks.url));
3215        v.validate(&valid_token()).await.unwrap();
3216
3217        jwks.delay_ms.store(1500, Ordering::SeqCst);
3218        let background = Arc::clone(&v);
3219        let refresh = tokio::spawn(async move { background.refresh_now().await });
3220        // And an unknown-kid request that also wants a refresh, queued behind it.
3221        let junk = Arc::clone(&v);
3222        let queued = tokio::spawn(async move {
3223            junk.validate(&mint(
3224                KEY_A_PEM,
3225                "unknown",
3226                &claims(serde_json::json!({"scope": "mcp:read"})),
3227            ))
3228            .await
3229        });
3230        tokio::time::sleep(Duration::from_millis(200)).await;
3231
3232        let fast = tokio::time::timeout(Duration::from_millis(500), v.validate(&valid_token()))
3233            .await
3234            .expect("a cached-key validation must not wait for the in-flight refresh");
3235        assert!(fast.is_ok());
3236        assert!(refresh.await.unwrap().is_ok());
3237        assert!(is_invalid(&queued.await.unwrap()));
3238    }
3239
3240    /// A caller that stops waiting mid-refetch (client disconnect, timeout
3241    /// layer) must not cancel the fetch: run inline, the drop would spend the
3242    /// unknown-`kid` cooldown with no keys loaded, and a legitimate token would
3243    /// then be refused for a minute.
3244    #[tokio::test]
3245    async fn a_dropped_validation_does_not_spend_the_refetch_cooldown() {
3246        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3247        jwks.delay_ms.store(500, Ordering::SeqCst);
3248        let v = validator(&jwks.url); // the real 60s cooldown
3249        assert!(
3250            tokio::time::timeout(Duration::from_millis(50), v.validate(&valid_token()))
3251                .await
3252                .is_err(),
3253            "the slow fetch outlives the caller"
3254        );
3255        jwks.delay_ms.store(0, Ordering::SeqCst);
3256        // The fetch the dropped call started finishes in its own task and loads
3257        // the key; this request waits for it rather than being refused.
3258        assert!(v.validate(&valid_token()).await.is_ok());
3259        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
3260    }
3261
3262    #[tokio::test]
3263    async fn the_background_task_stops_when_the_validator_is_dropped() {
3264        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3265        let v = Arc::new(validator(&jwks.url));
3266        let task = v.spawn_background_refresh();
3267        for _ in 0..200 {
3268            if jwks.hits.load(Ordering::SeqCst) > 0 {
3269                break;
3270            }
3271            tokio::time::sleep(Duration::from_millis(10)).await;
3272        }
3273        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
3274        let weak = Arc::downgrade(&v);
3275        drop(v);
3276        tokio::time::timeout(Duration::from_secs(5), task)
3277            .await
3278            .expect("the task ends with the validator, not after its hour-long sleep")
3279            .unwrap();
3280        assert!(
3281            weak.upgrade().is_none(),
3282            "the task held no strong reference"
3283        );
3284    }
3285
3286    #[tokio::test]
3287    async fn a_failed_refresh_keeps_the_keys_already_held() {
3288        let jwks = spawn_http_server(HashMap::new(), None).await;
3289        jwks.routes
3290            .lock()
3291            .unwrap()
3292            .insert("/jwks".to_string(), ("200 OK", jwks_body()));
3293        let v = validator_no_cooldown(&jwks.url);
3294        assert!(v.validate(&valid_token()).await.is_ok());
3295        jwks.routes.lock().unwrap().insert(
3296            "/jwks".to_string(),
3297            ("503 Service Unavailable", "{}".into()),
3298        );
3299        assert!(v.refresh_now().await.is_err());
3300        assert!(
3301            v.validate(&valid_token()).await.is_ok(),
3302            "an IdP outage must not revoke keys that are still good"
3303        );
3304    }
3305
3306    // ── key-set status and readiness ─────────────────────────────────────────
3307
3308    #[tokio::test]
3309    async fn the_status_before_any_load_is_empty_and_does_no_io() {
3310        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3311        let v = validator(&jwks.url);
3312        for _ in 0..10 {
3313            let status = v.key_set_status();
3314            assert_eq!(status.keys, 0);
3315            assert_eq!(status.jwks_uri.as_deref(), Some(jwks.url.as_str()));
3316            assert_eq!(status.last_attempt, None);
3317            assert_eq!(status.last_success, None);
3318            assert_eq!(status.last_error, None);
3319            assert!(!status.is_ready());
3320            assert!(!v.is_ready());
3321        }
3322        assert_eq!(
3323            jwks.hits.load(Ordering::SeqCst),
3324            0,
3325            "reading the status must never fetch"
3326        );
3327    }
3328
3329    #[tokio::test]
3330    async fn the_status_tracks_a_success_and_a_failure_keeps_the_keys() {
3331        let jwks = spawn_http_server(HashMap::new(), None).await;
3332        let set = |status: &'static str, body: String| {
3333            jwks.routes
3334                .lock()
3335                .unwrap()
3336                .insert("/jwks".to_string(), (status, body));
3337        };
3338        set("200 OK", jwks_of(&[jwk_rsa_a(), jwk_ec()]));
3339        let v = validator_no_cooldown(&jwks.url);
3340
3341        let before = std::time::SystemTime::now();
3342        assert_eq!(v.refresh_now().await.unwrap(), 2);
3343        let ok = v.key_set_status();
3344        assert_eq!(ok.keys, 2);
3345        assert!(ok.is_ready() && v.is_ready());
3346        assert_eq!(ok.jwks_uri.as_deref(), Some(jwks.url.as_str()));
3347        let success = ok.last_success.expect("a success is recorded");
3348        let attempt = ok.last_attempt.expect("the attempt is recorded");
3349        assert!(before <= attempt && attempt <= success);
3350        assert_eq!(ok.last_error, None);
3351
3352        set("503 Service Unavailable", "{}".into());
3353        let err = v.refresh_now().await.unwrap_err();
3354        let failed = v.key_set_status();
3355        assert_eq!(failed.last_error.as_ref(), Some(&err));
3356        assert_eq!(err.kind(), RefreshErrorKind::Fetch);
3357        assert_eq!(failed.keys, 2, "a failed refresh keeps the keys held");
3358        assert!(failed.is_ready() && v.is_ready());
3359        assert_eq!(failed.last_success, Some(success), "unchanged by a failure");
3360        assert!(failed.last_attempt.unwrap() >= success);
3361
3362        // Reading the status, however often, costs the IdP nothing.
3363        let hits = jwks.hits.load(Ordering::SeqCst);
3364        for _ in 0..10 {
3365            let _ = v.key_set_status();
3366            let _ = v.is_ready();
3367        }
3368        assert_eq!(jwks.hits.load(Ordering::SeqCst), hits);
3369
3370        // A later success clears the error.
3371        set("200 OK", jwks_body());
3372        assert_eq!(v.refresh_now().await.unwrap(), 1);
3373        let recovered = v.key_set_status();
3374        assert_eq!(recovered.last_error, None);
3375        assert_eq!(recovered.keys, 1);
3376        assert!(recovered.last_success.unwrap() >= success);
3377    }
3378
3379    #[tokio::test]
3380    async fn a_failed_first_load_leaves_the_validator_not_ready_with_the_error_kind() {
3381        let no_usable_keys = jwks_of(&[serde_json::json!({"kty": "oct", "k": "c2VjcmV0"})]);
3382        for (status_line, body, kind) in [
3383            (
3384                "503 Service Unavailable",
3385                "{}".to_string(),
3386                RefreshErrorKind::Fetch,
3387            ),
3388            ("200 OK", "not json".to_string(), RefreshErrorKind::Parse),
3389            (
3390                "200 OK",
3391                "{\"no\": \"keys\"}".to_string(),
3392                RefreshErrorKind::Parse,
3393            ),
3394            ("200 OK", no_usable_keys, RefreshErrorKind::NoUsableKeys),
3395        ] {
3396            let jwks = spawn_jwks_server(status_line, body).await;
3397            let v = validator(&jwks.url);
3398            let err = v.refresh_now().await.unwrap_err();
3399            let status = v.key_set_status();
3400            assert_eq!(err.kind(), kind, "{err}");
3401            assert_eq!(status.last_error, Some(err));
3402            assert_eq!(status.keys, 0);
3403            assert!(status.last_attempt.is_some());
3404            assert_eq!(status.last_success, None);
3405            assert!(!v.is_ready());
3406        }
3407    }
3408
3409    #[tokio::test]
3410    async fn the_status_reports_the_discovered_jwks_uri_and_discovery_failures() {
3411        let (server, issuer) =
3412            discovery_server("/application/o/wiki/", |i| i.to_string(), false).await;
3413        let v = discovering_validator(&issuer);
3414        assert_eq!(v.key_set_status().jwks_uri, None, "not yet discovered");
3415        v.refresh_now().await.unwrap();
3416        let status = v.key_set_status();
3417        assert_eq!(
3418            status.jwks_uri.as_deref(),
3419            Some(format!("{}/keys", server.base).as_str())
3420        );
3421        assert!(status.is_ready());
3422
3423        let (_server, issuer) =
3424            discovery_server("/app/", |_| "https://other.test/".into(), false).await;
3425        let v = discovering_validator(&issuer);
3426        let err = v.refresh_now().await.unwrap_err();
3427        assert_eq!(err.kind(), RefreshErrorKind::Discovery);
3428        let status = v.key_set_status();
3429        assert_eq!(status.jwks_uri, None);
3430        assert_eq!(status.last_error, Some(err));
3431    }
3432
3433    /// A `jwks_uri` carrying a credential two ways (userinfo, which the fetch
3434    /// sends as Basic auth, and a query): neither may surface in the status or
3435    /// the refresh error (`Display` or `Debug`), or in the rejection reason a
3436    /// request-driven refetch produces. Log lines are covered by
3437    /// `tests/redacted_logs.rs`, which needs a process-global subscriber.
3438    #[tokio::test]
3439    async fn a_credential_in_the_jwks_uri_never_reaches_the_status_or_errors() {
3440        let jwks = spawn_jwks_server("503 Service Unavailable", "{}".into()).await;
3441        let uri = format!(
3442            "{}?key=t0ken",
3443            jwks.url.replacen("http://", "http://alice:s3cret@", 1)
3444        );
3445        let shown = format!("{}?***", jwks.url.replacen("http://", "http://***@", 1));
3446        let v = validator_no_cooldown(&uri);
3447
3448        assert_eq!(v.key_set_status().jwks_uri.as_deref(), Some(shown.as_str()));
3449        let err = v.refresh_now().await.unwrap_err();
3450        let Err(TokenRejection::Invalid(reason)) = v.validate(&valid_token()).await else {
3451            panic!("no key can be loaded");
3452        };
3453        assert_eq!(jwks.hits.load(Ordering::SeqCst), 2, "both fetches went out");
3454        assert_eq!(reason.kind(), InvalidTokenKind::KeySetUnavailable);
3455        let reason = reason.to_string();
3456
3457        let status = v.key_set_status();
3458        assert_eq!(status.jwks_uri.as_deref(), Some(shown.as_str()));
3459        assert!(err.to_string().contains(&shown), "{err}");
3460        for text in [
3461            err.to_string(),
3462            format!("{err:?}"),
3463            status.last_error.as_ref().unwrap().to_string(),
3464            format!("{status:?}"),
3465            reason,
3466        ] {
3467            for secret in ["alice", "s3cret", "t0ken"] {
3468                assert!(!text.contains(secret), "{secret} leaked into: {text}");
3469            }
3470        }
3471    }
3472
3473    /// The fake server holds the JWKS response until the test releases it, so
3474    /// the status is read while the refresh lock is certainly held. On this
3475    /// single-threaded runtime a status read that waited on the refresh could
3476    /// never return (the refresh cannot progress while the test's task
3477    /// blocks), so a regression hangs — which the CI job timeout catches —
3478    /// rather than racing a wall-clock deadline.
3479    #[tokio::test]
3480    async fn the_status_does_not_wait_on_a_slow_refresh_in_flight() {
3481        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3482        let v = Arc::new(validator_no_cooldown(&jwks.url));
3483        v.refresh_now().await.unwrap();
3484        let loaded = v.key_set_status();
3485
3486        jwks.hold.store(true, Ordering::SeqCst);
3487        let background = Arc::clone(&v);
3488        let refresh = tokio::spawn(async move { background.refresh_now().await });
3489        // Wait until the request has reached the server, which is holding it.
3490        while !(v.keys.refresh_in_flight() && jwks.hits.load(Ordering::SeqCst) == 2) {
3491            tokio::task::yield_now().await;
3492        }
3493
3494        // Read on a thread of its own, with a deadline: were the status ever
3495        // to wait on the refresh in flight (which cannot finish until the
3496        // server is released below), this fails instead of hanging.
3497        let probe = Arc::clone(&v);
3498        let (sent, received) = std::sync::mpsc::channel();
3499        std::thread::spawn(move || {
3500            let _ = sent.send((probe.key_set_status(), probe.is_ready()));
3501        });
3502        let (status, ready) = received
3503            .recv_timeout(Duration::from_secs(10))
3504            .expect("the status read waited on the refresh in flight");
3505        assert!(
3506            v.keys.refresh_in_flight(),
3507            "read while the fetch was in flight"
3508        );
3509        assert!(!refresh.is_finished());
3510        assert_eq!(
3511            jwks.hits.load(Ordering::SeqCst),
3512            2,
3513            "reading the status fetched nothing"
3514        );
3515        assert!(ready);
3516        assert_eq!(status.keys, 1);
3517        assert_eq!(status.last_success, loaded.last_success);
3518        assert!(status.last_attempt > loaded.last_attempt);
3519        assert_eq!(status.last_error, None);
3520
3521        jwks.release.notify_one();
3522        assert!(refresh.await.unwrap().is_ok());
3523        assert_eq!(jwks.hits.load(Ordering::SeqCst), 2);
3524        assert!(v.key_set_status().last_success > loaded.last_success);
3525    }
3526
3527    /// Run every ready task and let pending loopback I/O complete WITHOUT
3528    /// moving the paused clock: `yield_now` parks the runtime with a zero
3529    /// timeout, which never auto-advances time (a `sleep` would, jumping past
3530    /// timers while a fetch is still in flight). Returns once no refresh is in
3531    /// flight and the background task has re-armed its timer.
3532    async fn settle(v: &OAuthValidator) {
3533        for _ in 0..100 {
3534            tokio::task::yield_now().await;
3535        }
3536        for _ in 0..100_000 {
3537            if !v.keys.refresh_in_flight() {
3538                break;
3539            }
3540            tokio::task::yield_now().await;
3541        }
3542        for _ in 0..100 {
3543            tokio::task::yield_now().await;
3544        }
3545    }
3546
3547    /// Move the paused clock forward by `secs`, settle, and return the
3548    /// server's hit count.
3549    async fn hits_after(v: &OAuthValidator, jwks: &FakeJwksServer, secs: f64) -> usize {
3550        tokio::time::advance(Duration::from_secs_f64(secs)).await;
3551        settle(v).await;
3552        jwks.hits.load(Ordering::SeqCst)
3553    }
3554
3555    /// Assert that the background task's next attempt comes exactly `gap`
3556    /// seconds after the previous one (which ran at the current clock): none
3557    /// half a second before, one half a second after. The clock ends at that
3558    /// attempt, ready for the next call.
3559    async fn next_attempt_after(v: &OAuthValidator, jwks: &FakeJwksServer, gap: f64) {
3560        let before = jwks.hits.load(Ordering::SeqCst);
3561        assert_eq!(
3562            hits_after(v, jwks, gap - 0.5).await,
3563            before,
3564            "no attempt before {gap}s"
3565        );
3566        assert_eq!(
3567            hits_after(v, jwks, 1.0).await,
3568            before + 1,
3569            "an attempt at {gap}s"
3570        );
3571    }
3572
3573    #[tokio::test(start_paused = true)]
3574    async fn a_failed_first_load_is_retried_quickly_until_keys_are_held() {
3575        let jwks = spawn_http_server(HashMap::new(), None).await;
3576        let set = |status: &'static str, body: String| {
3577            jwks.routes
3578                .lock()
3579                .unwrap()
3580                .insert("/jwks".to_string(), (status, body));
3581        };
3582        set("503 Service Unavailable", "{}".into());
3583        let v = Arc::new(validator(&jwks.url)); // the real 60 s cooldown
3584        let task = v.spawn_background_refresh();
3585        settle(&v).await;
3586        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1, "the first load");
3587        assert!(!v.is_ready());
3588
3589        // Keyless: 5 s, doubling, capped at 5 minutes.
3590        for gap in [5.0, 10.0, 20.0, 40.0, 80.0, 160.0, 300.0, 300.0] {
3591            next_attempt_after(&v, &jwks, gap).await;
3592        }
3593        assert!(!v.is_ready());
3594        assert_eq!(
3595            v.key_set_status().last_error.map(|e| e.kind()),
3596            Some(RefreshErrorKind::Fetch)
3597        );
3598
3599        // The authorization server recovers: the next keyless retry loads the
3600        // keys.
3601        set("200 OK", jwks_body());
3602        next_attempt_after(&v, &jwks, 300.0).await;
3603        assert!(v.is_ready());
3604        assert_eq!(v.key_set_status().last_error, None);
3605
3606        // With keys held the schedule is the pre-existing one: the hourly
3607        // pass, then — once it fails — a minute, not the 5 s keyless retry.
3608        set("503 Service Unavailable", "{}".into());
3609        next_attempt_after(&v, &jwks, 3600.0).await;
3610        next_attempt_after(&v, &jwks, 60.0).await;
3611        next_attempt_after(&v, &jwks, 120.0).await;
3612        assert!(v.is_ready(), "the failures kept the keys");
3613
3614        task.abort();
3615    }
3616
3617    #[tokio::test]
3618    async fn an_unreachable_jwks_endpoint_fails_closed() {
3619        // Port 1 refuses instantly.
3620        let v = validator("http://127.0.0.1:1/jwks");
3621        assert!(
3622            is_invalid(&v.validate(&valid_token()).await),
3623            "an IdP we cannot reach must mean 'no', never 'sure'"
3624        );
3625    }
3626
3627    #[tokio::test]
3628    async fn a_jwks_error_response_fails_closed() {
3629        let jwks = spawn_jwks_server("500 Internal Server Error", "{}".into()).await;
3630        let v = validator(&jwks.url);
3631        assert!(is_invalid(&v.validate(&valid_token()).await));
3632        // The refresh error carries the whole cause chain, outermost first.
3633        let err = v.refresh_now().await.unwrap_err().to_string();
3634        assert!(
3635            err.starts_with(&format!(
3636                "fetching the JWKS from {}: non-success status: ",
3637                jwks.url
3638            )),
3639            "{err}"
3640        );
3641        assert!(err.contains("500 Internal Server Error"), "{err}");
3642    }
3643
3644    #[tokio::test]
3645    async fn an_oversized_jwks_response_fails_closed() {
3646        let padding = "x".repeat(MAX_FETCH_BYTES);
3647        let body = format!("{{\"keys\":[{}],\"padding\":\"{padding}\"}}", jwk_rsa_a());
3648        let jwks = spawn_jwks_server("200 OK", body).await;
3649        let v = validator(&jwks.url);
3650        assert!(is_invalid(&v.validate(&valid_token()).await));
3651    }
3652
3653    #[tokio::test]
3654    async fn a_key_set_with_no_usable_keys_fails_closed() {
3655        let body = jwks_of(&[
3656            // Encryption key, symmetric key, a P-521 key ring cannot verify, and
3657            // an RSA key whose declared alg contradicts its type: none may verify.
3658            serde_json::json!({"kty": "RSA", "use": "enc", "kid": KID_A, "n": N_A, "e": "AQAB"}),
3659            serde_json::json!({"kty": "oct", "kid": "hmac", "k": "c2VjcmV0"}),
3660            serde_json::json!({"kty": "EC", "crv": "P-521", "kid": "p521", "x": "AA", "y": "AA"}),
3661            serde_json::json!({"kty": "RSA", "alg": "ES256", "kid": KID_A, "n": N_A, "e": "AQAB"}),
3662        ]);
3663        let jwks = spawn_jwks_server("200 OK", body).await;
3664        let v = validator(&jwks.url);
3665        assert!(is_invalid(&v.validate(&valid_token()).await));
3666        assert!(
3667            v.refresh_now()
3668                .await
3669                .unwrap_err()
3670                .to_string()
3671                .contains("fetching the JWKS")
3672        );
3673    }
3674
3675    #[tokio::test]
3676    async fn one_unparseable_key_does_not_take_the_usable_ones_down() {
3677        let body = jwks_of(&[
3678            serde_json::json!({"kty": "OKP", "crv": "X25519", "kid": "x", "x": "AA"}),
3679            serde_json::json!({"kty": "weird", "kid": "w"}),
3680            jwk_rsa_a(),
3681        ]);
3682        let jwks = spawn_jwks_server("200 OK", body).await;
3683        let v = validator(&jwks.url);
3684        assert!(v.validate(&valid_token()).await.is_ok());
3685    }
3686
3687    #[tokio::test]
3688    async fn a_kid_less_header_uses_the_single_compatible_key() {
3689        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3690        let v = validator(&jwks.url);
3691        let c = claims(serde_json::json!({"scope": "mcp:read"}));
3692        let token = mint_with(Algorithm::RS256, None, None, &c.clone());
3693        assert!(v.validate(&token).await.is_ok());
3694
3695        // Two RSA keys that could both verify RS256: refuse rather than try each.
3696        let jwks = spawn_jwks_server(
3697            "200 OK",
3698            jwks_of(&[jwk_rsa_a(), jwk_rsa_a_any_alg("second")]),
3699        )
3700        .await;
3701        let v = validator(&jwks.url);
3702        assert!(is_invalid(&v.validate(&token).await));
3703    }
3704
3705    // ── discovery ────────────────────────────────────────────────────────────
3706
3707    /// Serve OIDC discovery for `issuer_path` on a fake server whose document
3708    /// claims `doc_issuer`, plus the JWKS.
3709    async fn discovery_server(
3710        issuer_path: &str,
3711        doc_issuer: impl Fn(&str) -> String,
3712        via_rfc8414: bool,
3713    ) -> (FakeJwksServer, String) {
3714        let server = spawn_http_server(HashMap::new(), None).await;
3715        let issuer = format!("{}{issuer_path}", server.base);
3716        let doc = serde_json::json!({
3717            "issuer": doc_issuer(&issuer),
3718            "jwks_uri": format!("{}/keys", server.base),
3719        })
3720        .to_string();
3721        let well_known = if via_rfc8414 {
3722            format!(
3723                "/.well-known/oauth-authorization-server{}",
3724                issuer_path.trim_end_matches('/')
3725            )
3726        } else {
3727            format!(
3728                "{}/.well-known/openid-configuration",
3729                issuer_path.trim_end_matches('/')
3730            )
3731        };
3732        {
3733            let mut routes = server.routes.lock().unwrap();
3734            routes.insert(well_known, ("200 OK", doc));
3735            routes.insert("/keys".to_string(), ("200 OK", jwks_body()));
3736        }
3737        (server, issuer)
3738    }
3739
3740    fn discovering_validator(issuer: &str) -> OAuthValidator {
3741        let mut cfg = oauth_config("");
3742        cfg.issuer = issuer.to_string();
3743        validator_with(cfg)
3744    }
3745
3746    fn token_from(issuer: &str) -> String {
3747        mint(
3748            KEY_A_PEM,
3749            KID_A,
3750            &claims(serde_json::json!({"iss": issuer, "scope": "mcp:read"})),
3751        )
3752    }
3753
3754    #[tokio::test]
3755    async fn an_omitted_jwks_uri_is_discovered_once_from_oidc_metadata() {
3756        // Per-application issuer with a trailing slash — Authentik's shape.
3757        let (server, issuer) =
3758            discovery_server("/application/o/wiki/", |i| i.to_string(), false).await;
3759        let v = discovering_validator(&issuer);
3760        for _ in 0..3 {
3761            assert!(v.validate(&token_from(&issuer)).await.is_ok());
3762        }
3763        assert_eq!(
3764            server.hits.load(Ordering::SeqCst),
3765            2,
3766            "one discovery fetch and one JWKS fetch, then cached"
3767        );
3768    }
3769
3770    #[tokio::test]
3771    async fn discovery_falls_back_to_rfc_8414_metadata() {
3772        let (_server, issuer) = discovery_server("/tenant", |i| i.to_string(), true).await;
3773        let v = discovering_validator(&issuer);
3774        assert!(v.validate(&token_from(&issuer)).await.is_ok());
3775    }
3776
3777    #[tokio::test]
3778    async fn a_discovery_document_for_a_different_issuer_is_refused() {
3779        // The near miss again: the document drops the trailing slash.
3780        let (server, issuer) = discovery_server(
3781            "/application/o/wiki/",
3782            |i| i.trim_end_matches('/').to_string(),
3783            false,
3784        )
3785        .await;
3786        let v = discovering_validator(&issuer);
3787        assert!(is_invalid(&v.validate(&token_from(&issuer)).await));
3788        let err = v.refresh_now().await.unwrap_err().to_string();
3789        assert!(err.contains("does not match mcp.oauth.issuer"), "{err}");
3790        assert!(
3791            err.starts_with("could not discover a jwks_uri for mcp.oauth.issuer "),
3792            "{err}"
3793        );
3794        assert!(err.contains("set mcp.oauth.jwks_uri explicitly"), "{err}");
3795        // Two candidate URLs per attempt, two attempts, and the mismatching
3796        // document's jwks_uri was never followed.
3797        assert_eq!(server.hits.load(Ordering::SeqCst), 4);
3798    }
3799
3800    #[test]
3801    fn loopback_detection() {
3802        let url = |s: &str| reqwest::Url::parse(s).unwrap();
3803        assert!(is_loopback_url(&url("http://127.0.0.1:8080/x")));
3804        assert!(is_loopback_url(&url("http://[::1]:8080/x")));
3805        assert!(is_loopback_url(&url("http://localhost/x")));
3806        assert!(is_loopback_url(&url("http:/localhost/x")));
3807        assert!(is_loopback_url(&url("http://app.localhost/x")));
3808        assert!(!is_loopback_url(&url("http://auth.example.com/x")));
3809        assert!(!is_loopback_url(&url("http:auth.example.com/x")));
3810        assert!(!is_loopback_url(&url("data:text/plain,x")));
3811    }
3812
3813    #[tokio::test]
3814    async fn a_loopback_issuer_cannot_discover_a_cleartext_non_loopback_jwks_uri() {
3815        // A loopback issuer needs no opt-in; the key URL its metadata names
3816        // is still held to `allow_insecure_http`.
3817        let server = spawn_http_server(HashMap::new(), None).await;
3818        let issuer = format!("{}/app/", server.base);
3819        let doc =
3820            serde_json::json!({"issuer": issuer, "jwks_uri": "http://idp.example.invalid/keys"})
3821                .to_string();
3822        server.routes.lock().unwrap().insert(
3823            "/app/.well-known/openid-configuration".to_string(),
3824            ("200 OK", doc),
3825        );
3826        let v = discovering_validator(&issuer);
3827        let err = v.refresh_now().await.unwrap_err().to_string();
3828        assert!(err.contains("plain http on a non-loopback host"), "{err}");
3829        assert!(err.contains("mcp.oauth.allow_insecure_http"), "{err}");
3830    }
3831
3832    /// The loopback client resolves every name itself: a `*.localhost` name
3833    /// reaches this host even where the system resolver would send it to
3834    /// DNS (musl, some container resolvers), and the URL's port is kept.
3835    #[tokio::test]
3836    async fn a_localhost_name_is_fetched_from_this_host_whatever_dns_says() {
3837        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3838        for host in ["nonexistent-name.localhost", "localhost", "a.b.localhost"] {
3839            let url = jwks.url.replace("127.0.0.1", host);
3840            let v = validator_no_cooldown(&url);
3841            assert_eq!(v.refresh_now().await.unwrap(), 1, "{url}");
3842        }
3843        assert_eq!(jwks.hits.load(Ordering::SeqCst), 3);
3844    }
3845
3846    /// A discovered `jwks_uri` that stops working is dropped, so the next
3847    /// refresh re-reads the metadata and follows an authorization server that
3848    /// moved its JWKS — without a restart. A configured one is never
3849    /// replaced.
3850    #[tokio::test]
3851    async fn a_discovered_jwks_uri_that_fails_is_rediscovered() {
3852        let server = spawn_http_server(HashMap::new(), None).await;
3853        let issuer = format!("{}/app/", server.base);
3854        let doc = |path: &str| {
3855            serde_json::json!({"issuer": issuer, "jwks_uri": format!("{}{path}", server.base)})
3856                .to_string()
3857        };
3858        let set = |path: &str, status: &'static str, body: String| {
3859            server
3860                .routes
3861                .lock()
3862                .unwrap()
3863                .insert(path.to_string(), (status, body));
3864        };
3865        let hits = |path: &str| *server.path_hits.lock().unwrap().get(path).unwrap_or(&0);
3866        const METADATA: &str = "/app/.well-known/openid-configuration";
3867        set(METADATA, "200 OK", doc("/keys"));
3868        set("/keys", "200 OK", jwks_body());
3869        let mut cfg = oauth_config("");
3870        cfg.issuer = issuer.clone();
3871        let v = OAuthValidator::build(&cfg, Duration::ZERO).unwrap();
3872        assert_eq!(v.refresh_now().await.unwrap(), 1);
3873        assert_eq!(hits(METADATA), 1);
3874
3875        // The authorization server moves its JWKS and says so in its metadata.
3876        set(METADATA, "200 OK", doc("/keys2"));
3877        set("/keys2", "200 OK", jwks_of(&[jwk_ec()]));
3878        set("/keys", "404 Not Found", "{}".into());
3879        let err = v.refresh_now().await.unwrap_err();
3880        assert_eq!(err.kind(), RefreshErrorKind::Fetch);
3881        // The failed URI stays visible until the next discovery replaces it.
3882        assert_eq!(
3883            v.key_set_status().jwks_uri.as_deref(),
3884            Some(format!("{}/keys", server.base).as_str())
3885        );
3886        assert_eq!(v.key_set_status().keys, 1, "held keys are kept");
3887        assert_eq!(v.refresh_now().await.unwrap(), 1);
3888        assert_eq!(hits(METADATA), 2, "the metadata was read again");
3889        assert_eq!(hits("/keys2"), 1);
3890        assert_eq!(
3891            v.key_set_status().jwks_uri.as_deref(),
3892            Some(format!("{}/keys2", server.base).as_str())
3893        );
3894
3895        // A configured jwks_uri is never swapped for a discovered one.
3896        let v = validator_no_cooldown(&format!("{}/keys", server.base));
3897        assert!(v.refresh_now().await.is_err());
3898        assert!(v.refresh_now().await.is_err());
3899        assert_eq!(hits(METADATA), 2);
3900    }
3901
3902    /// Only a 2xx answer is a key set: a 302 with no `Location` (which
3903    /// reqwest cannot follow) and a JWKS body is refused, not parsed.
3904    #[tokio::test]
3905    async fn a_non_success_status_is_refused_even_with_a_jwks_body() {
3906        for status in [
3907            "302 Found",
3908            "304 Not Modified",
3909            "404 Not Found",
3910            "500 Internal Server Error",
3911        ] {
3912            let server = spawn_jwks_server(status, jwks_body()).await;
3913            let v = validator_no_cooldown(&server.url);
3914            let err = v.refresh_now().await.unwrap_err();
3915            assert_eq!(err.kind(), RefreshErrorKind::Fetch, "{status}");
3916            assert!(
3917                err.to_string().contains("non-success status"),
3918                "{status}: {err}"
3919            );
3920            assert!(!v.is_ready(), "{status}");
3921        }
3922    }
3923
3924    /// An RSA JWK that parses but could never verify a signature (an empty
3925    /// exponent, a modulus under 2048 bits) is skipped like an unparseable
3926    /// entry, so a key set of only such keys is no usable keys — not a
3927    /// "ready" validator that refuses every token.
3928    #[tokio::test]
3929    async fn an_rsa_key_that_cannot_verify_is_not_counted_as_held() {
3930        let mut empty_e = jwk_rsa_a();
3931        empty_e["e"] = serde_json::json!("");
3932        let mut short_n = jwk_rsa_a();
3933        short_n["n"] = serde_json::json!(b64url(&[0xc5; 255]));
3934        let server = spawn_jwks_server("200 OK", jwks_of(&[empty_e.clone(), short_n])).await;
3935        let v = validator_no_cooldown(&server.url);
3936        let err = v.refresh_now().await.unwrap_err();
3937        assert_eq!(err.kind(), RefreshErrorKind::NoUsableKeys);
3938        assert_eq!(v.key_set_status().keys, 0);
3939        assert!(!v.is_ready());
3940        // Next to a good key, only the good one counts.
3941        let server = spawn_jwks_server("200 OK", jwks_of(&[empty_e, jwk_rsa_a()])).await;
3942        let v = validator_no_cooldown(&server.url);
3943        assert_eq!(v.refresh_now().await.unwrap(), 1);
3944        assert!(v.validate(&valid_token()).await.is_ok());
3945    }
3946
3947    #[tokio::test]
3948    async fn a_redirect_from_loopback_to_a_non_loopback_host_is_refused() {
3949        // The status line carries a Location header: the fake server writes it
3950        // verbatim after `HTTP/1.1 `. Neither scheme and no opt-in lets a fetch
3951        // that started on loopback (on the proxy-free loopback client) leave it.
3952        for (target, allow_insecure_http) in [
3953            ("http://idp.example.invalid/keys", false),
3954            ("http://idp.example.invalid/keys", true),
3955            ("https://idp.example.invalid/keys", false),
3956        ] {
3957            let status: &'static str =
3958                Box::leak(format!("302 Found\r\nLocation: {target}").into_boxed_str());
3959            let server = spawn_http_server(
3960                HashMap::from([("/jwks".to_string(), (status, String::new()))]),
3961                None,
3962            )
3963            .await;
3964            let mut cfg = oauth_config(&server.url);
3965            cfg.allow_insecure_http = allow_insecure_http;
3966            let v = validator_with(cfg);
3967            let err = v.refresh_now().await.unwrap_err();
3968            assert_eq!(err.kind(), RefreshErrorKind::Fetch, "{target}");
3969            let err = err.to_string();
3970            assert!(
3971                err.contains("redirect from a loopback URL to a non-loopback host"),
3972                "{target}: {err}"
3973            );
3974            assert_eq!(server.hits.load(Ordering::SeqCst), 1, "{target}");
3975        }
3976    }
3977
3978    #[test]
3979    fn required_scopes_are_unadvertised_only_against_a_non_empty_menu() {
3980        let mut cfg = oauth_config("http://127.0.0.1/jwks");
3981        cfg.required_scopes = vec!["mcp:read".to_string()];
3982        cfg.scopes_supported = vec!["mcp:write".to_string()];
3983        assert_eq!(unadvertised_scopes(&cfg), ["mcp:read"]);
3984        cfg.scopes_supported = vec!["mcp:read".to_string(), "mcp:write".to_string()];
3985        assert!(unadvertised_scopes(&cfg).is_empty());
3986        // An empty menu makes the challenge name the required scopes, so nothing
3987        // is unadvertised.
3988        cfg.scopes_supported = Vec::new();
3989        assert!(unadvertised_scopes(&cfg).is_empty());
3990        let challenge = OAuthValidator::new(&cfg).unwrap().invalid_token_challenge();
3991        assert!(challenge.contains(r#"scope="mcp:read""#), "{challenge}");
3992    }
3993
3994    // ── regression: the production Authentik shape, unchanged ────────────────
3995
3996    /// The config block of the original production deployment (mcp-md-wiki),
3997    /// using ONLY the keys it had before provider-agnostic validation. Parsed from
3998    /// YAML when the `serde` feature is on, built literally otherwise.
3999    fn production_authentik_config(issuer: &str) -> crate::OAuthConfig {
4000        #[cfg(feature = "serde")]
4001        {
4002            let yaml = format!(
4003                "enabled: true\n\
4004                 issuer: \"{issuer}\"\n\
4005                 jwks_uri: \"{issuer}jwks/\"\n\
4006                 audience: \"example-client-id\"\n\
4007                 resource: \"https://kb.example.com/mcp\"\n\
4008                 required_scope: \"mcp:read\"\n\
4009                 scopes_supported: [\"mcp:read\", \"mcp:write\"]\n"
4010            );
4011            serde_yaml_ng::from_str(&yaml).unwrap()
4012        }
4013        #[cfg(not(feature = "serde"))]
4014        {
4015            crate::OAuthConfig {
4016                enabled: true,
4017                issuer: issuer.to_string(),
4018                jwks_uri: Some(format!("{issuer}jwks/")),
4019                audience: "example-client-id".into(),
4020                resource: "https://kb.example.com/mcp".into(),
4021                required_scope: Some("mcp:read".into()),
4022                scopes_supported: Some(vec!["mcp:read".into(), "mcp:write".into()]),
4023                ..crate::OAuthConfig::default()
4024            }
4025        }
4026    }
4027
4028    /// The exact config and token shape of the original production deployment
4029    /// (Authentik, per-application issuer with a trailing slash, JWKS at
4030    /// `<issuer>jwks/`, `aud` = the OAuth client_id as a string, `scope` a
4031    /// space-delimited string, RS256, header `typ: JWT`). It must validate with
4032    /// every newer key at its default. Hostnames are placeholders; the fake server
4033    /// stands in for the AS.
4034    #[tokio::test]
4035    async fn production_authentik_config_and_token_still_pass_unchanged() {
4036        let server = spawn_http_server(HashMap::new(), None).await;
4037        let issuer = format!("{}/application/o/example-app/", server.base);
4038        server.routes.lock().unwrap().insert(
4039            "/application/o/example-app/jwks/".to_string(),
4040            ("200 OK", jwks_body()),
4041        );
4042        let parsed = production_authentik_config(&issuer);
4043        let cfg = parsed
4044            .resolve(crate::KeyNaming::Dotted("mcp.oauth"))
4045            .unwrap()
4046            .expect("enabled");
4047        assert!(
4048            cfg.accept_static_bearer,
4049            "dual mode must stay on by default"
4050        );
4051        assert_eq!(cfg.required_scopes, ["mcp:read"]);
4052        let v = validator_with(cfg);
4053
4054        let token = mint_with(
4055            Algorithm::RS256,
4056            Some(KID_A),
4057            Some("JWT"),
4058            &serde_json::json!({
4059                "iss": issuer,
4060                "sub": "0000000000000000example",
4061                "aud": "example-client-id",
4062                "azp": "example-client-id",
4063                "exp": now() + 300,
4064                "iat": now(),
4065                "auth_time": now(),
4066                "acr": "goauthentik.io/providers/oauth2/default",
4067                "email": "user@example.com",
4068                "email_verified": true,
4069                "name": "Example User",
4070                "given_name": "Example User",
4071                "preferred_username": "example",
4072                "nickname": "example",
4073                "groups": ["wiki-users"],
4074                "scope": "openid email profile mcp:read mcp:write",
4075            }),
4076        );
4077        let t = v.validate(&token).await.unwrap();
4078        assert_eq!(t.principal.as_deref(), Some("example"));
4079        assert_eq!(
4080            t.scopes,
4081            ["openid", "email", "profile", "mcp:read", "mcp:write"]
4082        );
4083        // And the metadata and challenges are what they were before
4084        // provider-agnostic validation.
4085        assert_eq!(v.metadata()["authorization_servers"][0], issuer.as_str());
4086        assert!(
4087            v.invalid_token_challenge()
4088                .starts_with("Bearer error=\"invalid_token\", resource_metadata=")
4089        );
4090        assert_eq!(
4091            v.insufficient_scope_challenge(),
4092            "Bearer error=\"insufficient_scope\", scope=\"mcp:read\", \
4093             resource_metadata=\"https://kb.example.com/.well-known/oauth-protected-resource/mcp\""
4094        );
4095    }
4096
4097    // ── observed shapes: sandbox-tested authorization servers ────────────────
4098    //
4099    // These mirror token shapes captured from real Authelia 4.39.4 and Kanidm
4100    // sandboxes. Hostnames and ids are placeholders.
4101
4102    async fn accepts(
4103        cfg_edit: impl FnOnce(&mut ResolvedOAuthConfig),
4104        alg: Algorithm,
4105        kid: &str,
4106        typ: Option<&str>,
4107        token_claims: serde_json::Value,
4108    ) -> AuthorizedToken {
4109        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
4110        let mut cfg = oauth_config(&jwks.url);
4111        cfg_edit(&mut cfg);
4112        let v = validator_with(cfg);
4113        v.validate(&mint_with(alg, Some(kid), typ, &token_claims))
4114            .await
4115            .unwrap()
4116    }
4117
4118    #[tokio::test]
4119    async fn observed_shape_authelia_4_39_scp_array_and_resource_url_audience() {
4120        let issuer = "https://auth.example.com";
4121        let resource = "https://kb.example.com/mcp";
4122        let t = accepts(
4123            |c| {
4124                c.issuer = issuer.into();
4125                c.audience = resource.into();
4126                c.require_at_jwt = true;
4127            },
4128            Algorithm::RS256,
4129            "test-key-a-pss",
4130            Some("at+jwt"),
4131            serde_json::json!({
4132                "iss": issuer, "aud": [resource], "client_id": "example-client",
4133                "sub": "44726d41-0000-4000-8000-000000000000",
4134                "exp": now() + 3600, "iat": now(), "nbf": now(),
4135                "jti": "x", "scp": ["mcp:read", "mcp:write"],
4136            }),
4137        )
4138        .await;
4139        assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
4140        // No username claim in Authelia access tokens: the chain lands on `sub`.
4141        assert_eq!(
4142            t.principal.as_deref(),
4143            Some("44726d41-0000-4000-8000-000000000000")
4144        );
4145    }
4146
4147    #[tokio::test]
4148    async fn observed_shape_kanidm_es256_per_client_issuer_and_client_audience() {
4149        let issuer = "https://idm.example.com/oauth2/openid/example-client";
4150        let t = accepts(
4151            |c| {
4152                c.issuer = issuer.into();
4153                c.audience = "example-client".into();
4154                c.require_at_jwt = true;
4155            },
4156            Algorithm::ES256,
4157            KID_EC,
4158            Some("at+jwt"),
4159            serde_json::json!({
4160                "iss": issuer, "aud": "example-client", "client_id": "example-client",
4161                "sub": "00000000-0000-4000-8000-000000000001",
4162                "exp": now() + 900, "iat": now(), "nbf": now(), "jti": "x",
4163                "scope": "mcp:read openid profile",
4164            }),
4165        )
4166        .await;
4167        assert!(t.has_scope("mcp:read"));
4168    }
4169
4170    // ── documented-shape fixtures, NOT live-tested ───────────────────────────
4171    //
4172    // Each models the access-token shape the named authorization server
4173    // documents (or, where noted, its source code shows), to prove the generic
4174    // validator covers it with config alone. None of these has been run against
4175    // the real product; they are "documented-shape fixture, not live-tested" and
4176    // must not be cited as compatibility claims.
4177
4178    #[tokio::test]
4179    async fn documented_shape_fixture_not_live_tested_keycloak() {
4180        // Realm issuer, `typ` JWT (at+jwt is an opt-in client switch since 26.2),
4181        // `scope` string, `azp` = client, `preferred_username` present.
4182        let issuer = "https://sso.example.com/realms/home";
4183        let t = accepts(
4184            |c| {
4185                c.issuer = issuer.into();
4186                c.audience = "wiki".into();
4187            },
4188            Algorithm::RS256,
4189            KID_A,
4190            Some("JWT"),
4191            serde_json::json!({
4192                "iss": issuer, "aud": ["wiki", "account"], "azp": "wiki",
4193                "sub": "u", "exp": now() + 300, "typ": "Bearer",
4194                "preferred_username": "alice", "scope": "openid profile mcp:read",
4195            }),
4196        )
4197        .await;
4198        assert_eq!(t.principal.as_deref(), Some("alice"));
4199    }
4200
4201    #[tokio::test]
4202    async fn documented_shape_fixture_not_live_tested_okta_custom_as() {
4203        // Custom authorization server: no `typ` header at all, `scp` array,
4204        // `aud` = the configured API audience, `cid` = client.
4205        let issuer = "https://example.okta.com/oauth2/default";
4206        let t = accepts(
4207            |c| {
4208                c.issuer = issuer.into();
4209                c.audience = "api://default".into();
4210            },
4211            Algorithm::RS256,
4212            KID_A,
4213            None,
4214            serde_json::json!({
4215                "iss": issuer, "aud": "api://default", "cid": "client", "sub": "a@example.com",
4216                "exp": now() + 3600, "scp": ["openid", "mcp:read"],
4217            }),
4218        )
4219        .await;
4220        assert!(t.has_scope("mcp:read"));
4221    }
4222
4223    #[tokio::test]
4224    async fn documented_shape_fixture_not_live_tested_entra_id_v2() {
4225        // v2.0 tenant issuer, `typ` JWT, `scp` space-delimited string, `aud` = the
4226        // API's client id.
4227        let issuer = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0";
4228        let t = accepts(
4229            |c| {
4230                c.issuer = issuer.into();
4231                c.audience = "11111111-1111-1111-1111-111111111111".into();
4232            },
4233            Algorithm::RS256,
4234            KID_A,
4235            Some("JWT"),
4236            serde_json::json!({
4237                "iss": issuer, "aud": "11111111-1111-1111-1111-111111111111",
4238                "sub": "pairwise", "oid": "o", "exp": now() + 3600,
4239                "preferred_username": "alice@example.com", "scp": "mcp.read mcp:read",
4240            }),
4241        )
4242        .await;
4243        assert!(t.has_scope("mcp:read"));
4244    }
4245
4246    #[tokio::test]
4247    async fn documented_shape_fixture_not_live_tested_auth0() {
4248        // Issuer with a trailing slash, `aud` array (API identifier + userinfo),
4249        // `scope` string, both the Auth0 (`typ` JWT) and RFC 9068 (`at+jwt`)
4250        // profiles.
4251        let issuer = "https://tenant.example.auth0.com/";
4252        for typ in ["JWT", "at+jwt"] {
4253            let t = accepts(
4254                |c| {
4255                    c.issuer = issuer.into();
4256                    c.audience = "https://kb.example.com/mcp".into();
4257                },
4258                Algorithm::RS256,
4259                KID_A,
4260                Some(typ),
4261                serde_json::json!({
4262                    "iss": issuer,
4263                    "aud": ["https://kb.example.com/mcp", "https://tenant.example.auth0.com/userinfo"],
4264                    "azp": "client", "sub": "auth0|1", "exp": now() + 3600,
4265                    "scope": "openid mcp:read",
4266                }),
4267            )
4268            .await;
4269            assert!(t.has_scope("mcp:read"));
4270        }
4271    }
4272
4273    #[tokio::test]
4274    async fn documented_shape_fixture_not_live_tested_ory_hydra_jwt_strategy() {
4275        // Only with `strategies.access_token: jwt` (the default is opaque);
4276        // `scp` is a list by default, a string with `oauth2.jwt.scope_claim: string`.
4277        let issuer = "https://hydra.example.com/";
4278        for scp in [
4279            serde_json::json!(["mcp:read"]),
4280            serde_json::json!("offline mcp:read"),
4281        ] {
4282            let t = accepts(
4283                |c| {
4284                    c.issuer = issuer.into();
4285                    c.audience = "https://kb.example.com/mcp".into();
4286                },
4287                Algorithm::RS256,
4288                KID_A,
4289                Some("JWT"),
4290                serde_json::json!({
4291                    "iss": issuer, "aud": ["https://kb.example.com/mcp"], "sub": "u",
4292                    "client_id": "c", "exp": now() + 3600, "scp": scp, "ext": {},
4293                }),
4294            )
4295            .await;
4296            assert!(t.has_scope("mcp:read"));
4297        }
4298    }
4299
4300    #[tokio::test]
4301    async fn documented_shape_fixture_not_live_tested_logto_resource_indicator() {
4302        // `aud` = the registered API resource indicator (RFC 8707), `scope`
4303        // string, ES256 among its allowed signing algorithms.
4304        let issuer = "https://logto.example.com/oidc";
4305        let t = accepts(
4306            |c| {
4307                c.issuer = issuer.into();
4308                c.audience = "https://kb.example.com/mcp".into();
4309            },
4310            Algorithm::ES256,
4311            KID_EC,
4312            None,
4313            serde_json::json!({
4314                "iss": issuer, "aud": "https://kb.example.com/mcp", "sub": "u",
4315                "client_id": "c", "exp": now() + 3600, "scope": "mcp:read",
4316            }),
4317        )
4318        .await;
4319        assert!(t.has_scope("mcp:read"));
4320    }
4321
4322    #[tokio::test]
4323    async fn documented_shape_fixture_not_live_tested_casdoor_jwt_standard() {
4324        // Source-derived: no `typ` beyond jsonwebtoken's default, `aud` =
4325        // [client_id] (or [resource] when RFC 8707 is used), `scope` string,
4326        // `preferred_username` with the JWT-Standard token format.
4327        let issuer = "https://casdoor.example.com";
4328        let t = accepts(
4329            |c| {
4330                c.issuer = issuer.into();
4331                c.audience = "example-client-id".into();
4332            },
4333            Algorithm::RS256,
4334            KID_A,
4335            Some("JWT"),
4336            serde_json::json!({
4337                "iss": issuer, "aud": ["example-client-id"], "sub": "u",
4338                "exp": now() + 3600, "preferred_username": "alice",
4339                "scope": "openid mcp:read",
4340            }),
4341        )
4342        .await;
4343        assert_eq!(t.principal.as_deref(), Some("alice"));
4344    }
4345
4346    #[tokio::test]
4347    async fn documented_shape_fixture_not_live_tested_rauthy_eddsa_at_jwt() {
4348        // Source-derived: `typ` at+jwt, `scope` string, EdDSA available per
4349        // client, no `preferred_username` (the principal chain falls to `sub`).
4350        let issuer = "https://rauthy.example.com/auth/v1";
4351        let t = accepts(
4352            |c| {
4353                c.issuer = issuer.into();
4354                c.audience = "example-client".into();
4355                c.require_at_jwt = true;
4356            },
4357            Algorithm::EdDSA,
4358            KID_ED,
4359            Some("at+jwt"),
4360            serde_json::json!({
4361                "iss": issuer, "aud": "example-client", "azp": "example-client",
4362                "sub": "user-id", "exp": now() + 1800, "scope": "openid mcp:read",
4363            }),
4364        )
4365        .await;
4366        assert_eq!(t.principal.as_deref(), Some("user-id"));
4367    }
4368
4369    #[tokio::test]
4370    async fn documented_shape_fixture_not_live_tested_dex_needs_a_group_claim_as_scope() {
4371        // Source-derived: Dex's access token is an ID token (`aud` = client_id, no
4372        // `scope`/`scp` claim at all). The only generic way to gate it is to read
4373        // a group claim as the scope source — a compromise, see the design notes.
4374        let issuer = "https://dex.example.com";
4375        let t = accepts(
4376            |c| {
4377                c.issuer = issuer.into();
4378                c.audience = "example-client".into();
4379                c.scope_claims = vec!["groups".into()];
4380                c.required_scopes = vec!["wiki-users".into()];
4381            },
4382            Algorithm::RS256,
4383            KID_A,
4384            None,
4385            serde_json::json!({
4386                "iss": issuer, "aud": "example-client", "sub": "u",
4387                "exp": now() + 3600, "email": "a@example.com",
4388                "groups": ["wiki-users", "admins"],
4389            }),
4390        )
4391        .await;
4392        assert!(t.has_scope("wiki-users"));
4393    }
4394
4395    #[tokio::test]
4396    async fn documented_shape_fixture_not_live_tested_zitadel_jwt_mode() {
4397        // Only with the application's token type switched to JWT (opaque is the
4398        // alternative). `aud` holds the client ids and the project id.
4399        // Zitadel's scope claim shape is not documented where we looked; this
4400        // fixture exercises the aud-array/project-id part only.
4401        let issuer = "https://zitadel.example.com";
4402        let t = accepts(
4403            |c| {
4404                c.issuer = issuer.into();
4405                c.audience = "123456789012345678".into();
4406            },
4407            Algorithm::RS256,
4408            KID_A,
4409            None,
4410            serde_json::json!({
4411                "iss": issuer,
4412                "aud": ["234567890123456789@wiki", "123456789012345678"],
4413                "client_id": "234567890123456789@wiki", "sub": "u",
4414                "exp": now() + 3600, "scope": "openid mcp:read",
4415            }),
4416        )
4417        .await;
4418        assert!(t.has_scope("mcp:read"));
4419    }
4420
4421    // ── verified claims and token metadata ───────────────────────────────────
4422
4423    fn at(secs: u64) -> std::time::SystemTime {
4424        std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)
4425    }
4426
4427    #[tokio::test]
4428    async fn issuer_expiry_and_audience_string_are_filled_from_the_token() {
4429        let exp = now() + 1800;
4430        let t = scopes_of(serde_json::json!({"scope": "mcp:read", "exp": exp}))
4431            .await
4432            .unwrap();
4433        assert_eq!(t.issuer, ISSUER);
4434        assert_eq!(t.audiences, [AUDIENCE]);
4435        assert_eq!(t.expires_at, at(exp));
4436    }
4437
4438    #[tokio::test]
4439    async fn audience_array_is_normalized_to_a_list() {
4440        let t = scopes_of(serde_json::json!({
4441            "scope": "mcp:read", "aud": ["https://other.example.test", AUDIENCE],
4442        }))
4443        .await
4444        .unwrap();
4445        assert_eq!(t.audiences, ["https://other.example.test", AUDIENCE]);
4446    }
4447
4448    #[tokio::test]
4449    async fn client_id_claim_wins_over_azp() {
4450        let t = scopes_of(serde_json::json!({
4451            "scope": "mcp:read", "client_id": "client-1", "azp": "client-2",
4452        }))
4453        .await
4454        .unwrap();
4455        assert_eq!(t.client_id.as_deref(), Some("client-1"));
4456    }
4457
4458    #[tokio::test]
4459    async fn client_id_falls_back_to_azp() {
4460        let t = scopes_of(serde_json::json!({"scope": "mcp:read", "azp": "client-2"}))
4461            .await
4462            .unwrap();
4463        assert_eq!(t.client_id.as_deref(), Some("client-2"));
4464    }
4465
4466    #[tokio::test]
4467    async fn client_id_is_none_without_client_id_or_azp() {
4468        let t = scopes_of(serde_json::json!({"scope": "mcp:read"}))
4469            .await
4470            .unwrap();
4471        assert_eq!(t.client_id, None);
4472        // An empty or non-string value is not a client id either.
4473        let t = scopes_of(serde_json::json!({"scope": "mcp:read", "client_id": "", "azp": 7}))
4474            .await
4475            .unwrap();
4476        assert_eq!(t.client_id, None);
4477    }
4478
4479    #[tokio::test]
4480    async fn issued_at_is_read_when_present_and_none_when_absent() {
4481        let iat = now() - 60;
4482        let t = scopes_of(serde_json::json!({"scope": "mcp:read", "iat": iat}))
4483            .await
4484            .unwrap();
4485        assert_eq!(t.issued_at, Some(at(iat)));
4486        let t = scopes_of(serde_json::json!({"scope": "mcp:read"}))
4487            .await
4488            .unwrap();
4489        assert_eq!(t.issued_at, None);
4490    }
4491
4492    #[tokio::test]
4493    async fn jti_is_read_when_present_and_none_when_absent() {
4494        let t = scopes_of(serde_json::json!({"scope": "mcp:read", "jti": "id-42"}))
4495            .await
4496            .unwrap();
4497        assert_eq!(t.jti.as_deref(), Some("id-42"));
4498        let t = scopes_of(serde_json::json!({"scope": "mcp:read"}))
4499            .await
4500            .unwrap();
4501        assert_eq!(t.jti, None);
4502    }
4503
4504    #[tokio::test]
4505    async fn claims_returns_custom_claims_and_claims_as_round_trips() {
4506        #[derive(serde::Deserialize, Debug, PartialEq)]
4507        struct AppClaims {
4508            sub: String,
4509            email: String,
4510            groups: Vec<String>,
4511            #[serde(default)]
4512            tenant: Option<String>,
4513        }
4514        let t = scopes_of(serde_json::json!({
4515            "scope": "mcp:read",
4516            "email": "ada@example.com",
4517            "groups": ["admins", "dev"],
4518        }))
4519        .await
4520        .unwrap();
4521        assert_eq!(t.claims()["groups"], serde_json::json!(["admins", "dev"]));
4522        assert_eq!(t.claims()["iss"], ISSUER);
4523        let typed: AppClaims = t.claims_as().unwrap();
4524        assert_eq!(
4525            typed,
4526            AppClaims {
4527                sub: "user-1".into(),
4528                email: "ada@example.com".into(),
4529                groups: vec!["admins".into(), "dev".into()],
4530                tenant: None,
4531            }
4532        );
4533        // A shape mismatch is an error, not a panic.
4534        #[derive(serde::Deserialize, Debug)]
4535        struct NeedsTenant {
4536            #[allow(dead_code)]
4537            tenant: String,
4538        }
4539        assert!(t.claims_as::<NeedsTenant>().is_err());
4540    }
4541
4542    #[tokio::test]
4543    async fn debug_shows_claim_names_but_never_claim_values() {
4544        let t = scopes_of(serde_json::json!({
4545            "scope": "mcp:read", "email": "private-address@example.com",
4546        }))
4547        .await
4548        .unwrap();
4549        let shown = format!("{t:?}");
4550        assert!(shown.contains("\"email\""));
4551        assert!(!shown.contains("private-address@example.com"));
4552    }
4553
4554    #[tokio::test]
4555    async fn an_out_of_range_exp_and_iat_saturate_instead_of_reading_as_expired() {
4556        // jsonwebtoken accepts an `exp` up to u64::MAX; SystemTime cannot hold it.
4557        let t = scopes_of(serde_json::json!({
4558            "scope": "mcp:read",
4559            "exp": 10_000_000_000_000_000_000u64,
4560            "iat": 10_000_000_000_000_000_000u64,
4561        }))
4562        .await
4563        .unwrap();
4564        let max = at(253_402_300_799);
4565        assert_eq!(t.expires_at, max);
4566        assert_eq!(t.issued_at, Some(max));
4567    }
4568
4569    #[tokio::test]
4570    async fn fractional_exp_and_iat_are_rounded_like_jsonwebtoken_rounds_them() {
4571        // jsonwebtoken (`numeric_type`) does `value.round() as u64` before it
4572        // checks `exp`, so `base + 0.5` reads as `base + 1` (half away from zero).
4573        let base = now() + 1000;
4574        let iat = now() - 100;
4575        let t = scopes_of(serde_json::json!({
4576            "scope": "mcp:read",
4577            "exp": base as f64 + 0.5,
4578            "iat": iat as f64 + 0.4,
4579        }))
4580        .await
4581        .unwrap();
4582        assert_eq!(t.expires_at, at(base + 1));
4583        assert_eq!(t.issued_at, Some(at(iat)));
4584    }
4585
4586    #[tokio::test]
4587    async fn a_string_iat_is_ignored_and_the_token_is_still_accepted() {
4588        // jsonwebtoken never reads `iat`, so a token with a junk one validates
4589        // today; issued_at is just None.
4590        let t = scopes_of(serde_json::json!({"scope": "mcp:read", "iat": "yesterday"}))
4591            .await
4592            .unwrap();
4593        assert_eq!(t.issued_at, None);
4594    }
4595
4596    // ── rejection kinds ──────────────────────────────────────────────────────
4597
4598    fn kind_of<T: std::fmt::Debug>(r: Result<T, TokenRejection>) -> InvalidTokenKind {
4599        match r {
4600            Err(TokenRejection::Invalid(invalid)) => invalid.kind(),
4601            other => panic!("expected Invalid, got {other:?}"),
4602        }
4603    }
4604
4605    /// `claims` plus the required scope plus `extra`.
4606    fn scoped(extra: serde_json::Value) -> serde_json::Value {
4607        let mut c = claims(serde_json::json!({"scope": "mcp:read"}));
4608        for (k, val) in extra.as_object().unwrap() {
4609            c[k] = val.clone();
4610        }
4611        c
4612    }
4613
4614    /// Every kind a validation can refuse with, each reached through the real
4615    /// path: a minted token (or header junk) against a live fake JWKS server.
4616    #[tokio::test]
4617    async fn every_validator_refusal_names_its_kind() {
4618        use InvalidTokenKind as K;
4619        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
4620        let v = validator_no_cooldown(&jwks.url);
4621        let good = scoped(serde_json::json!({}));
4622        let mut no_exp = good.clone();
4623        no_exp.as_object_mut().unwrap().remove("exp");
4624        let later = now() + 365 * 24 * 3600;
4625        let bad_signature_encoding = {
4626            let t = mint(KEY_A_PEM, KID_A, &good);
4627            format!("{}.@@@", &t[..t.rfind('.').unwrap()])
4628        };
4629
4630        let cases: Vec<(String, InvalidTokenKind)> = vec![
4631            ("a".repeat(MAX_TOKEN_BYTES + 1), K::TooLarge),
4632            ("not-a-jwt".into(), K::NotJwt),
4633            ("@@@.e30.sig".into(), K::MalformedHeader),
4634            (
4635                mint_raw_header(serde_json::json!({"alg": "none"}), good.clone()),
4636                K::MalformedHeader,
4637            ),
4638            (
4639                mint_raw_header(
4640                    serde_json::json!({"alg": "RS256", "kid": KID_A, "crit": ["x"]}),
4641                    good.clone(),
4642                ),
4643                K::CriticalHeader,
4644            ),
4645            (
4646                mint_raw_header(
4647                    serde_json::json!({"alg": "HS256", "kid": KID_A}),
4648                    good.clone(),
4649                ),
4650                K::AlgorithmNotAllowed,
4651            ),
4652            (
4653                mint_with(
4654                    crate::Algorithm::RS256,
4655                    Some(KID_A),
4656                    Some("dpop+jwt"),
4657                    &good,
4658                ),
4659                K::TypeNotAllowed,
4660            ),
4661            (mint(KEY_A_PEM, "no-such-kid", &good), K::KeyNotFound),
4662            (
4663                mint(KEY_A_PEM, KID_A, &"a JSON string, not an object"),
4664                K::MalformedToken,
4665            ),
4666            (bad_signature_encoding, K::MalformedToken),
4667            (mint(KEY_B_PEM, KID_A, &good), K::BadSignature),
4668            (
4669                mint(
4670                    KEY_A_PEM,
4671                    KID_A,
4672                    &scoped(serde_json::json!({"exp": now() - 3600})),
4673                ),
4674                K::Expired,
4675            ),
4676            (
4677                mint(KEY_A_PEM, KID_A, &scoped(serde_json::json!({"nbf": later}))),
4678                K::NotYetValid,
4679            ),
4680            (
4681                mint(
4682                    KEY_A_PEM,
4683                    KID_A,
4684                    &scoped(serde_json::json!({"iss": "https://other.example.test/"})),
4685                ),
4686                K::WrongIssuer,
4687            ),
4688            (
4689                mint(
4690                    KEY_A_PEM,
4691                    KID_A,
4692                    &scoped(serde_json::json!({"iss": [ISSUER]})),
4693                ),
4694                K::WrongIssuer,
4695            ),
4696            (
4697                mint(
4698                    KEY_A_PEM,
4699                    KID_A,
4700                    &scoped(serde_json::json!({"aud": "someone-else"})),
4701                ),
4702                K::WrongAudience,
4703            ),
4704            (mint(KEY_A_PEM, KID_A, &no_exp), K::MissingClaim),
4705            (
4706                mint(
4707                    KEY_A_PEM,
4708                    KID_A,
4709                    &scoped(serde_json::json!({"nbf": "later"})),
4710                ),
4711                K::MalformedClaim,
4712            ),
4713            (
4714                mint(
4715                    KEY_A_PEM,
4716                    KID_A,
4717                    &scoped(serde_json::json!({"cnf": {"jkt": "x"}})),
4718                ),
4719                K::SenderConstrained,
4720            ),
4721        ];
4722        for (token, expected) in cases {
4723            let got = kind_of(v.validate(&token).await);
4724            assert_eq!(got, expected, "token {}", for_log(&token));
4725        }
4726        // The same kinds come out of the cache-only pass `authenticate` runs,
4727        // and out of `authenticate`, which reports the first candidate's.
4728        let expired = mint(
4729            KEY_A_PEM,
4730            KID_A,
4731            &scoped(serde_json::json!({"exp": now() - 3600})),
4732        );
4733        let CachedAttempt::Decided(r) = v.validate_cached(&expired).await else {
4734            panic!("the key is cached by now");
4735        };
4736        assert_eq!(kind_of(r), K::Expired);
4737        assert_eq!(
4738            kind_of(crate::authenticate([expired.as_str(), "x"], None, Some(&v)).await),
4739            K::Expired
4740        );
4741    }
4742
4743    /// An authorization server that cannot be reached is `KeySetUnavailable`
4744    /// (an outage worth its own alert), not a verdict on the token.
4745    #[tokio::test]
4746    async fn an_unreachable_key_set_is_key_set_unavailable() {
4747        // Real 60 s cooldown: the first request's refetch fails, and the two
4748        // after it land inside the cooldown with no key held and the last
4749        // attempt failed — still an outage, not an unknown key.
4750        let v = validator("http://127.0.0.1:1/jwks");
4751        for attempt in 0..3 {
4752            assert_eq!(
4753                kind_of(v.validate(&valid_token()).await),
4754                InvalidTokenKind::KeySetUnavailable,
4755                "attempt {attempt}"
4756            );
4757        }
4758        let jwks = spawn_jwks_server("500 Internal Server Error", "{}".into()).await;
4759        let v = validator(&jwks.url);
4760        assert_eq!(
4761            kind_of(v.validate(&valid_token()).await),
4762            InvalidTokenKind::KeySetUnavailable
4763        );
4764    }
4765
4766    /// An unknown `kid` during the refetch cooldown is `KeyNotFound`, like one
4767    /// missing from a freshly fetched set.
4768    #[tokio::test]
4769    async fn an_unknown_kid_in_the_cooldown_is_key_not_found() {
4770        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
4771        let v = validator(&jwks.url);
4772        let token = mint(
4773            KEY_A_PEM,
4774            "rotated-key",
4775            &claims(serde_json::json!({"scope": "mcp:read"})),
4776        );
4777        for _ in 0..2 {
4778            assert_eq!(
4779                kind_of(v.validate(&token).await),
4780                InvalidTokenKind::KeyNotFound
4781            );
4782        }
4783        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
4784    }
4785
4786    // ── claim policy: allowed_client_ids, max_token_age_secs, required_claims ─
4787
4788    /// A validator with `adjust` applied to the fixture config.
4789    async fn policy_validator(
4790        adjust: impl FnOnce(&mut ResolvedOAuthConfig),
4791    ) -> (FakeJwksServer, OAuthValidator) {
4792        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
4793        let mut cfg = oauth_config(&jwks.url);
4794        adjust(&mut cfg);
4795        let v = validator_with(cfg);
4796        (jwks, v)
4797    }
4798
4799    async fn outcome(v: &OAuthValidator, extra: serde_json::Value) -> Result<(), InvalidTokenKind> {
4800        match v.validate(&mint(KEY_A_PEM, KID_A, &scoped(extra))).await {
4801            Ok(_) => Ok(()),
4802            Err(TokenRejection::Invalid(invalid)) => Err(invalid.kind()),
4803            Err(other) => panic!("expected Ok or Invalid, got {other:?}"),
4804        }
4805    }
4806
4807    #[tokio::test]
4808    async fn allowed_client_ids_match_client_id_then_azp() {
4809        use InvalidTokenKind as K;
4810        let (_jwks, v) = policy_validator(|c| {
4811            c.allowed_client_ids = vec!["client-a".into(), "client-b".into()];
4812        })
4813        .await;
4814        let cases = [
4815            (serde_json::json!({"client_id": "client-a"}), Ok(())),
4816            (serde_json::json!({"azp": "client-b"}), Ok(())),
4817            // A `client_id` that is present but empty or not a string is
4818            // refused outright: it never falls through to a listed `azp`.
4819            (
4820                serde_json::json!({"client_id": "", "azp": "client-a"}),
4821                Err(K::ClientNotAllowed),
4822            ),
4823            (
4824                serde_json::json!({"client_id": 7, "azp": "client-a"}),
4825                Err(K::ClientNotAllowed),
4826            ),
4827            (
4828                serde_json::json!({"client_id": null, "azp": "client-a"}),
4829                Err(K::ClientNotAllowed),
4830            ),
4831            (
4832                serde_json::json!({"client_id": ["client-a"], "azp": "client-a"}),
4833                Err(K::ClientNotAllowed),
4834            ),
4835            // `client_id` wins over `azp`, so a listed `azp` cannot rescue an
4836            // unlisted `client_id`.
4837            (
4838                serde_json::json!({"client_id": "client-x", "azp": "client-a"}),
4839                Err(K::ClientNotAllowed),
4840            ),
4841            (
4842                serde_json::json!({"azp": "client-x"}),
4843                Err(K::ClientNotAllowed),
4844            ),
4845            // Exact, case-sensitive.
4846            (
4847                serde_json::json!({"client_id": "CLIENT-A"}),
4848                Err(K::ClientNotAllowed),
4849            ),
4850            // No client at all, or not as a string.
4851            (serde_json::json!({}), Err(K::ClientNotAllowed)),
4852            (
4853                serde_json::json!({"client_id": 7}),
4854                Err(K::ClientNotAllowed),
4855            ),
4856        ];
4857        for (extra, want) in cases {
4858            assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
4859        }
4860        // A disallowed client is a 401 even when the scope is missing too:
4861        // the claim policy runs before the scope check.
4862        let unscoped = mint(
4863            KEY_A_PEM,
4864            KID_A,
4865            &claims(serde_json::json!({"client_id": "client-x"})),
4866        );
4867        assert_eq!(kind_of(v.validate(&unscoped).await), K::ClientNotAllowed);
4868        // And a listed client still needs its scope: 403.
4869        let unscoped = mint(
4870            KEY_A_PEM,
4871            KID_A,
4872            &claims(serde_json::json!({"client_id": "client-a"})),
4873        );
4874        assert_eq!(
4875            v.validate(&unscoped).await.unwrap_err(),
4876            TokenRejection::InsufficientScope
4877        );
4878        // The accepted token reports the client the check used.
4879        let t = v
4880            .validate(&mint(
4881                KEY_A_PEM,
4882                KID_A,
4883                &scoped(serde_json::json!({"azp": "client-b"})),
4884            ))
4885            .await
4886            .unwrap();
4887        assert_eq!(t.client_id.as_deref(), Some("client-b"));
4888        // Without the allowlist, the accessor keeps its "first non-empty
4889        // string" reading: an empty `client_id` falls through to `azp`.
4890        let (_jwks, open) = policy_validator(|_| {}).await;
4891        let t = open
4892            .validate(&mint(
4893                KEY_A_PEM,
4894                KID_A,
4895                &scoped(serde_json::json!({"client_id": "", "azp": "client-b"})),
4896            ))
4897            .await
4898            .unwrap();
4899        assert_eq!(t.client_id.as_deref(), Some("client-b"));
4900    }
4901
4902    #[tokio::test]
4903    async fn max_token_age_bounds_iat_with_the_leeway() {
4904        use InvalidTokenKind as K;
4905        let (_jwks, v) = policy_validator(|c| {
4906            c.max_token_age_secs = Some(600);
4907            c.leeway_secs = 60;
4908        })
4909        .await;
4910        let t = now();
4911        let cases = [
4912            (serde_json::json!({"iat": t}), Ok(())),
4913            (serde_json::json!({"iat": t - 600}), Ok(())),
4914            // Within the leeway past the limit: accepted.
4915            (serde_json::json!({"iat": t - 600 - 50}), Ok(())),
4916            (
4917                serde_json::json!({"iat": t - 600 - 70}),
4918                Err(K::TokenTooOld),
4919            ),
4920            (serde_json::json!({"iat": 0}), Err(K::TokenTooOld)),
4921            // A fractional iat is rounded as jsonwebtoken rounds exp.
4922            (serde_json::json!({"iat": t as f64 - 0.4}), Ok(())),
4923            // Issued in the future: within the leeway is clock skew, beyond it
4924            // is refused (it would otherwise dodge the age bound).
4925            (serde_json::json!({"iat": t + 50}), Ok(())),
4926            (serde_json::json!({"iat": t + 3600}), Err(K::NotYetValid)),
4927            (serde_json::json!({"iat": u64::MAX}), Err(K::NotYetValid)),
4928            // With the bound set, `iat` is required and must be a NumericDate.
4929            (serde_json::json!({}), Err(K::MissingClaim)),
4930            (
4931                serde_json::json!({"iat": t.to_string()}),
4932                Err(K::MalformedClaim),
4933            ),
4934            (serde_json::json!({"iat": -1}), Err(K::MalformedClaim)),
4935            (serde_json::json!({"iat": null}), Err(K::MalformedClaim)),
4936        ];
4937        for (extra, want) in cases {
4938            assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
4939        }
4940    }
4941
4942    #[tokio::test]
4943    async fn required_claims_match_exactly_or_by_array_membership() {
4944        use InvalidTokenKind as K;
4945        let (_jwks, v) = policy_validator(|c| {
4946            c.required_claims = [
4947                ("tid".to_string(), serde_json::json!("tenant-1")),
4948                ("groups".to_string(), serde_json::json!("api-users")),
4949            ]
4950            .into_iter()
4951            .collect();
4952        })
4953        .await;
4954        let ok = serde_json::json!({"tid": "tenant-1", "groups": ["x", "api-users"]});
4955        let cases = [
4956            (ok.clone(), Ok(())),
4957            (
4958                serde_json::json!({"tid": "tenant-1", "groups": "api-users"}),
4959                Ok(()),
4960            ),
4961            (
4962                serde_json::json!({"tid": ["tenant-1"], "groups": "api-users"}),
4963                Ok(()),
4964            ),
4965            (
4966                serde_json::json!({"tid": "tenant-2", "groups": "api-users"}),
4967                Err(K::ClaimMismatch),
4968            ),
4969            (
4970                serde_json::json!({"tid": "tenant-1", "groups": ["admins"]}),
4971                Err(K::ClaimMismatch),
4972            ),
4973            (
4974                serde_json::json!({"tid": "tenant-1", "groups": []}),
4975                Err(K::ClaimMismatch),
4976            ),
4977            (
4978                serde_json::json!({"tid": "tenant-1", "groups": null}),
4979                Err(K::ClaimMismatch),
4980            ),
4981            (
4982                serde_json::json!({"tid": "tenant-1", "groups": {"api-users": true}}),
4983                Err(K::ClaimMismatch),
4984            ),
4985            // Nested arrays are not searched.
4986            (
4987                serde_json::json!({"tid": "tenant-1", "groups": [["api-users"]]}),
4988                Err(K::ClaimMismatch),
4989            ),
4990            (
4991                serde_json::json!({"groups": "api-users"}),
4992                Err(K::MissingClaim),
4993            ),
4994            (serde_json::json!({"tid": "tenant-1"}), Err(K::MissingClaim)),
4995        ];
4996        for (extra, want) in cases {
4997            assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
4998        }
4999
5000        // Numbers and booleans: JSON equality, so the type matters.
5001        let (_jwks, v) = policy_validator(|c| {
5002            c.required_claims = [
5003                ("level".to_string(), serde_json::json!(3)),
5004                ("mfa".to_string(), serde_json::json!(true)),
5005            ]
5006            .into_iter()
5007            .collect();
5008        })
5009        .await;
5010        let cases = [
5011            (serde_json::json!({"level": 3, "mfa": true}), Ok(())),
5012            (serde_json::json!({"level": [1, 3], "mfa": true}), Ok(())),
5013            (
5014                serde_json::json!({"level": "3", "mfa": true}),
5015                Err(K::ClaimMismatch),
5016            ),
5017            (
5018                serde_json::json!({"level": 3, "mfa": "true"}),
5019                Err(K::ClaimMismatch),
5020            ),
5021            (
5022                serde_json::json!({"level": 3, "mfa": false}),
5023                Err(K::ClaimMismatch),
5024            ),
5025        ];
5026        for (extra, want) in cases {
5027            assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
5028        }
5029    }
5030
5031    /// The three checks run in order (client, age, claims), after the
5032    /// signature, and every one of them is off by default: a token that
5033    /// carries none of `client_id`, `azp`, `iat` or any custom claim
5034    /// validates exactly as before.
5035    #[tokio::test]
5036    async fn the_claim_policy_is_off_by_default_and_never_runs_before_the_signature() {
5037        let (_jwks, v) = policy_validator(|_| {}).await;
5038        assert!(v.config().allowed_client_ids.is_empty());
5039        assert_eq!(v.config().max_token_age_secs, None);
5040        assert!(v.config().required_claims.is_empty());
5041        let bare = claims(serde_json::json!({"scope": "mcp:read"}));
5042        for claim in ["client_id", "azp", "iat"] {
5043            assert!(bare.get(claim).is_none());
5044        }
5045        assert!(v.validate(&mint(KEY_A_PEM, KID_A, &bare)).await.is_ok());
5046
5047        let (_jwks, v) = policy_validator(|c| {
5048            c.allowed_client_ids = vec!["client-a".into()];
5049            c.max_token_age_secs = Some(600);
5050            c.required_claims = [("tid".to_string(), serde_json::json!("t"))]
5051                .into_iter()
5052                .collect();
5053        })
5054        .await;
5055        // A forged token carrying every wanted claim is still a bad signature.
5056        let wanted = scoped(serde_json::json!({"client_id": "client-a", "iat": now(), "tid": "t"}));
5057        assert_eq!(
5058            kind_of(v.validate(&mint(KEY_B_PEM, KID_A, &wanted)).await),
5059            InvalidTokenKind::BadSignature
5060        );
5061        assert!(v.validate(&mint(KEY_A_PEM, KID_A, &wanted)).await.is_ok());
5062        // An expired token is `Expired`, not judged by the policy.
5063        let mut expired = wanted.clone();
5064        expired["exp"] = serde_json::json!(now() - 3600);
5065        assert_eq!(
5066            kind_of(v.validate(&mint(KEY_A_PEM, KID_A, &expired)).await),
5067            InvalidTokenKind::Expired
5068        );
5069        // Order: the client check is reported first, then the age.
5070        let mut both = wanted.clone();
5071        both["client_id"] = serde_json::json!("client-x");
5072        both.as_object_mut().unwrap().remove("iat");
5073        assert_eq!(
5074            kind_of(v.validate(&mint(KEY_A_PEM, KID_A, &both)).await),
5075            InvalidTokenKind::ClientNotAllowed
5076        );
5077        let mut age_and_claim = wanted.clone();
5078        age_and_claim.as_object_mut().unwrap().remove("iat");
5079        age_and_claim["tid"] = serde_json::json!("other");
5080        assert_eq!(
5081            kind_of(v.validate(&mint(KEY_A_PEM, KID_A, &age_and_claim)).await),
5082            InvalidTokenKind::MissingClaim
5083        );
5084    }
5085
5086    /// Documented-shape fixture, not live-tested: restricting a shared
5087    /// audience to known clients. Auth0 names the client in `azp`, which
5088    /// `allowed_client_ids` reads; Okta's custom authorization server names it
5089    /// in `cid`, which it does not, so an Okta deployment requires `cid` with
5090    /// `required_claims` instead.
5091    #[tokio::test]
5092    async fn documented_shape_fixture_not_live_tested_shared_audience_client_restriction() {
5093        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5094
5095        let auth0 = "https://tenant.example.auth0.com/";
5096        let mut cfg = oauth_config(&jwks.url);
5097        cfg.issuer = auth0.into();
5098        cfg.audience = "https://kb.example.com/mcp".into();
5099        cfg.allowed_client_ids = vec!["client".into()];
5100        let v = validator_with(cfg);
5101        let auth0_token = |azp: &str| {
5102            mint(
5103                KEY_A_PEM,
5104                KID_A,
5105                &serde_json::json!({
5106                    "iss": auth0,
5107                    "aud": ["https://kb.example.com/mcp", "https://tenant.example.auth0.com/userinfo"],
5108                    "azp": azp, "sub": "auth0|1", "exp": now() + 3600,
5109                    "scope": "openid mcp:read",
5110                }),
5111            )
5112        };
5113        assert!(v.validate(&auth0_token("client")).await.is_ok());
5114        assert_eq!(
5115            kind_of(v.validate(&auth0_token("another-client")).await),
5116            InvalidTokenKind::ClientNotAllowed
5117        );
5118
5119        let okta = "https://example.okta.com/oauth2/default";
5120        let mut cfg = oauth_config(&jwks.url);
5121        cfg.issuer = okta.into();
5122        cfg.audience = "api://default".into();
5123        cfg.required_claims = [("cid".to_string(), serde_json::json!("client"))]
5124            .into_iter()
5125            .collect();
5126        let v = validator_with(cfg);
5127        let okta_token = |cid: &str| {
5128            mint(
5129                KEY_A_PEM,
5130                KID_A,
5131                &serde_json::json!({
5132                    "iss": okta, "aud": "api://default", "cid": cid, "sub": "a@example.com",
5133                    "exp": now() + 3600, "scp": ["openid", "mcp:read"],
5134                }),
5135            )
5136        };
5137        assert!(v.validate(&okta_token("client")).await.is_ok());
5138        assert_eq!(
5139            kind_of(v.validate(&okta_token("another-client")).await),
5140            InvalidTokenKind::ClaimMismatch
5141        );
5142    }
5143
5144    /// With keys held, an unknown `kid` inside the cooldown is `KeyNotFound`
5145    /// while the IdP is healthy, and `KeySetUnavailable` while the last
5146    /// refresh failed: the kind follows the key set's state, not just the
5147    /// cooldown.
5148    #[tokio::test]
5149    async fn the_cooldown_kind_follows_the_last_refresh() {
5150        let server = spawn_http_server(
5151            HashMap::from([("/jwks".to_string(), ("200 OK", jwks_body()))]),
5152            None,
5153        )
5154        .await;
5155        let v = validator(&format!("{}/jwks", server.base)); // real 60 s cooldown
5156        v.refresh_now().await.unwrap();
5157        let unknown = mint(
5158            KEY_A_PEM,
5159            "rotated-key",
5160            &claims(serde_json::json!({"scope": "mcp:read"})),
5161        );
5162        assert_eq!(
5163            kind_of(v.validate(&unknown).await),
5164            InvalidTokenKind::KeyNotFound
5165        );
5166        // The IdP goes down; a refresh fails and is recorded.
5167        server.routes.lock().unwrap().insert(
5168            "/jwks".to_string(),
5169            ("500 Internal Server Error", "{}".to_string()),
5170        );
5171        assert!(v.refresh_now().await.is_err());
5172        for _ in 0..2 {
5173            assert_eq!(
5174                kind_of(v.validate(&unknown).await),
5175                InvalidTokenKind::KeySetUnavailable
5176            );
5177        }
5178        // A token whose key is held is still accepted meanwhile.
5179        assert!(v.validate(&valid_token()).await.is_ok());
5180        // Healthy again: back to `KeyNotFound`.
5181        server
5182            .routes
5183            .lock()
5184            .unwrap()
5185            .insert("/jwks".to_string(), ("200 OK", jwks_body()));
5186        v.refresh_now().await.unwrap();
5187        assert_eq!(
5188            kind_of(v.validate(&unknown).await),
5189            InvalidTokenKind::KeyNotFound
5190        );
5191    }
5192
5193    /// A required claim that is present but unreadable is `MalformedClaim`;
5194    /// only an absent one is `MissingClaim`.
5195    #[tokio::test]
5196    async fn an_unreadable_required_claim_is_malformed_not_missing() {
5197        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5198        let v = validator(&jwks.url);
5199        for extra in [
5200            serde_json::json!({"exp": "tomorrow"}),
5201            serde_json::json!({"exp": null}),
5202            serde_json::json!({"iss": 7}),
5203            serde_json::json!({"aud": 7}),
5204            serde_json::json!({"aud": {"id": AUDIENCE}}),
5205        ] {
5206            let token = mint(KEY_A_PEM, KID_A, &scoped(extra.clone()));
5207            assert_eq!(
5208                kind_of(v.validate(&token).await),
5209                InvalidTokenKind::MalformedClaim,
5210                "{extra}"
5211            );
5212        }
5213        for name in ["exp", "iss", "aud"] {
5214            let mut c = scoped(serde_json::json!({}));
5215            c.as_object_mut().unwrap().remove(name);
5216            let token = mint(KEY_A_PEM, KID_A, &c);
5217            assert_eq!(
5218                kind_of(v.validate(&token).await),
5219                InvalidTokenKind::MissingClaim,
5220                "{name}"
5221            );
5222        }
5223    }
5224
5225    #[test]
5226    fn required_claims_on_scope_or_client_claims_are_warned_about_not_refused() {
5227        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
5228        cfg.scope_claims.push("roles".into());
5229        cfg.required_claims = ["scope", "scp", "roles", "azp", "client_id", "tid"]
5230            .into_iter()
5231            .map(|n| (n.to_string(), serde_json::json!("x")))
5232            .collect();
5233        let named: Vec<&str> = required_claim_footguns(&cfg)
5234            .into_iter()
5235            .map(|(n, _)| n)
5236            .collect();
5237        assert_eq!(named, ["azp", "client_id", "roles", "scope", "scp"]);
5238        // Still builds: a warning, never a refusal.
5239        assert!(OAuthValidator::new(&cfg).is_ok());
5240        cfg.required_claims = [("tid".to_string(), serde_json::json!("x"))]
5241            .into_iter()
5242            .collect();
5243        assert!(required_claim_footguns(&cfg).is_empty());
5244    }
5245
5246    // ── lifted from the crate reviews: fetch bounds and fail-closed paths ────
5247
5248    /// A raw TCP server on 127.0.0.1 writing `chunks()` (the whole response,
5249    /// head included) to every connection, `pause` apart. Returns its JWKS
5250    /// URL and how many bytes it managed to write before the client left.
5251    async fn raw_http_server(
5252        chunks: impl Fn() -> Vec<Vec<u8>> + Send + Sync + 'static,
5253        pause: Duration,
5254    ) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
5255        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5256        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5257        let addr = listener.local_addr().unwrap();
5258        let written = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5259        let counter = Arc::clone(&written);
5260        let chunks = Arc::new(chunks);
5261        tokio::spawn(async move {
5262            while let Ok((mut sock, _)) = listener.accept().await {
5263                let counter = Arc::clone(&counter);
5264                let chunks = Arc::clone(&chunks);
5265                tokio::spawn(async move {
5266                    let mut request = vec![0u8; 4096];
5267                    let _ = sock.read(&mut request).await;
5268                    for chunk in chunks() {
5269                        if sock.write_all(&chunk).await.is_err() {
5270                            return;
5271                        }
5272                        counter.fetch_add(chunk.len(), Ordering::SeqCst);
5273                        if !pause.is_zero() {
5274                            tokio::time::sleep(pause).await;
5275                        }
5276                    }
5277                });
5278            }
5279        });
5280        (format!("http://{addr}/jwks"), written)
5281    }
5282
5283    /// The byte cap holds while STREAMING a body that announces no length
5284    /// (chunked, never ending): the fetch stops soon after the cap instead
5285    /// of reading on. The `Content-Length` precheck alone would never see it.
5286    #[tokio::test]
5287    async fn the_byte_cap_is_enforced_while_streaming_a_chunked_body() {
5288        let (url, written) = raw_http_server(
5289            || {
5290                let mut out = vec![
5291                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
5292                      Transfer-Encoding: chunked\r\n\r\n"
5293                        .to_vec(),
5294                ];
5295                let chunk = "x".repeat(32 * 1024);
5296                for _ in 0..1000 {
5297                    out.push(format!("{:x}\r\n{chunk}\r\n", chunk.len()).into_bytes());
5298                }
5299                out
5300            },
5301            Duration::from_millis(1),
5302        )
5303        .await;
5304        let v = validator_no_cooldown(&url);
5305        let err = tokio::time::timeout(Duration::from_secs(10), v.refresh_now())
5306            .await
5307            .expect("the fetch stops at the cap")
5308            .unwrap_err();
5309        assert_eq!(err.kind(), RefreshErrorKind::Fetch);
5310        assert!(err.to_string().contains("response exceeds the"), "{err}");
5311        tokio::time::sleep(Duration::from_millis(200)).await;
5312        let sent = written.load(Ordering::SeqCst);
5313        assert!(
5314            sent < 2 * MAX_FETCH_BYTES + 64 * 1024,
5315            "the server wrote {sent} bytes before the client went away"
5316        );
5317
5318        // A well-formed chunked key set is read as usual.
5319        let (url, _) = raw_http_server(
5320            || {
5321                let body = jwks_body();
5322                vec![
5323                    format!(
5324                        "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n{body}\r\n0\r\n\r\n",
5325                        body.len()
5326                    )
5327                    .into_bytes(),
5328                ]
5329            },
5330            Duration::ZERO,
5331        )
5332        .await;
5333        assert_eq!(validator_no_cooldown(&url).refresh_now().await.unwrap(), 1);
5334    }
5335
5336    /// A `Content-Length` over the cap is refused before a byte of the body is
5337    /// read — even when the body actually sent is a small, valid key set,
5338    /// which the streaming cap alone would accept.
5339    #[tokio::test]
5340    async fn a_content_length_over_the_cap_is_refused_before_reading() {
5341        let (url, _) = raw_http_server(
5342            || {
5343                vec![
5344                    format!(
5345                        "HTTP/1.1 200 OK\r\nContent-Length: 99999999\r\n\r\n{}",
5346                        jwks_body()
5347                    )
5348                    .into_bytes(),
5349                ]
5350            },
5351            Duration::ZERO,
5352        )
5353        .await;
5354        let err = validator_no_cooldown(&url).refresh_now().await.unwrap_err();
5355        assert_eq!(err.kind(), RefreshErrorKind::Fetch);
5356        assert!(
5357            err.to_string()
5358                .contains(&format!("over the {MAX_FETCH_BYTES}-byte cap")),
5359            "{err}"
5360        );
5361        // A body of exactly the cap is accepted.
5362        let mut body = jwks_body();
5363        let pad = MAX_FETCH_BYTES - body.len() - ",\"p\":\"\"".len();
5364        body.insert_str(body.len() - 1, &format!(",\"p\":\"{}\"", "y".repeat(pad)));
5365        assert_eq!(body.len(), MAX_FETCH_BYTES);
5366        let jwks = spawn_jwks_server("200 OK", body).await;
5367        assert_eq!(
5368            validator_no_cooldown(&jwks.url)
5369                .refresh_now()
5370                .await
5371                .unwrap(),
5372            1
5373        );
5374    }
5375
5376    /// A FETCHED key set is held to `MAX_JWKS_KEYS` exactly as a seeded one
5377    /// is: the 65th entry is dropped, not the set.
5378    #[tokio::test]
5379    async fn a_fetched_key_set_keeps_only_the_first_64_keys() {
5380        let keys: Vec<serde_json::Value> = (0..=crate::jwks::MAX_JWKS_KEYS)
5381            .map(|i| jwk_rsa_a_any_alg(&format!("k{i}")))
5382            .collect();
5383        let jwks = spawn_jwks_server("200 OK", jwks_of(&keys)).await;
5384        let v = validator_no_cooldown(&jwks.url);
5385        assert_eq!(v.refresh_now().await.unwrap(), crate::jwks::MAX_JWKS_KEYS);
5386        let body = claims(serde_json::json!({"scope": "mcp:read"}));
5387        assert!(v.validate(&mint(KEY_A_PEM, "k63", &body)).await.is_ok());
5388        match v.validate(&mint(KEY_A_PEM, "k64", &body)).await {
5389            Err(TokenRejection::Invalid(invalid)) => {
5390                assert_eq!(invalid.kind(), InvalidTokenKind::KeyNotFound)
5391            }
5392            other => panic!("{other:?}"),
5393        }
5394    }
5395
5396    /// An explicit proxy carries every non-loopback fetch, but never a
5397    /// redirect hop to loopback (`PROXY_BYPASS`): the hop goes direct.
5398    #[tokio::test]
5399    async fn an_explicit_proxy_is_bypassed_for_a_redirect_hop_to_loopback() {
5400        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5401        let target = "http://jwks.example.test/jwks";
5402        for hop in [
5403            jwks.url.clone(),
5404            jwks.url.replace("127.0.0.1", "localhost"),
5405            jwks.url.replace("127.0.0.1", "127.0.0.9"),
5406        ] {
5407            // The proxy answers the first, non-loopback fetch with a redirect
5408            // to `hop`; were the hop proxied too, it would see a second
5409            // request (and answer it 404).
5410            let status: &'static str =
5411                Box::leak(format!("302 Found\r\nLocation: {hop}").into_boxed_str());
5412            let proxy = spawn_http_server(
5413                HashMap::from([(target.to_string(), (status, String::new()))]),
5414                None,
5415            )
5416            .await;
5417            let mut cfg = oauth_config(target);
5418            cfg.allow_insecure_http = true;
5419            let v = OAuthValidator::builder(&cfg)
5420                .proxy(&proxy.base)
5421                .build()
5422                .unwrap();
5423            let result = v.refresh_now().await;
5424            assert_eq!(proxy.hits.load(Ordering::SeqCst), 1, "{hop}: proxy hits");
5425            if hop.contains("127.0.0.9") {
5426                // Nothing listens there: refused on a direct connect.
5427                assert!(result.is_err(), "{hop}");
5428            } else {
5429                assert_eq!(result.unwrap(), 1, "{hop}");
5430            }
5431        }
5432    }
5433
5434    /// A correctly signed token over the 16 KiB cap is refused as `TooLarge`
5435    /// before anything is decoded or fetched — its size alone decides.
5436    #[tokio::test]
5437    async fn a_signed_token_over_the_size_cap_is_too_large_without_a_fetch() {
5438        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5439        let v = validator(&jwks.url);
5440        // A token of an exact length: a long `kid` gets near it, then a
5441        // short padding claim fine-tunes it (base64url lengths skip every
5442        // value that is 1 mod 4, so one knob alone cannot hit every length).
5443        let token = |kid_len: usize, pad: usize| {
5444            let body = claims(serde_json::json!({"scope": "mcp:read", "pad": "p".repeat(pad)}));
5445            mint(KEY_A_PEM, &"k".repeat(kid_len), &body)
5446        };
5447        let (mut kid_len, mut too_long) = (0, MAX_TOKEN_BYTES);
5448        while too_long - kid_len > 1 {
5449            let mid = (kid_len + too_long) / 2;
5450            if token(mid, 0).len() <= MAX_TOKEN_BYTES {
5451                kid_len = mid;
5452            } else {
5453                too_long = mid;
5454            }
5455        }
5456        let exactly = |len: usize| {
5457            (kid_len.saturating_sub(8)..=kid_len)
5458                .flat_map(|k| (0..8).map(move |p| (k, p)))
5459                .map(|(k, p)| token(k, p))
5460                .find(|t| t.len() == len)
5461                .expect("a token of exactly that length")
5462        };
5463        let at_cap = exactly(MAX_TOKEN_BYTES);
5464        let over = exactly(MAX_TOKEN_BYTES + 1);
5465        assert_eq!(over.len(), MAX_TOKEN_BYTES + 1);
5466        match v.validate(&over).await {
5467            Err(TokenRejection::Invalid(invalid)) => {
5468                assert_eq!(invalid.kind(), InvalidTokenKind::TooLarge)
5469            }
5470            other => panic!("{other:?}"),
5471        }
5472        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0, "no key fetch");
5473        // At the cap it is judged on its merits (an unknown kid).
5474        match v.validate(&at_cap).await {
5475            Err(TokenRejection::Invalid(invalid)) => {
5476                assert_eq!(invalid.kind(), InvalidTokenKind::KeyNotFound)
5477            }
5478            other => panic!("{other:?}"),
5479        }
5480    }
5481
5482    /// Keys already held survive a refresh that fails on a PARSE error (a key
5483    /// set that is not JSON, not a JWK Set, or holds no usable key) and on a
5484    /// DISCOVERY failure — not only on a failed fetch.
5485    #[tokio::test]
5486    async fn held_keys_survive_a_parse_or_discovery_failure() {
5487        let server = spawn_http_server(HashMap::new(), None).await;
5488        let set = |path: &str, status: &'static str, body: String| {
5489            server
5490                .routes
5491                .lock()
5492                .unwrap()
5493                .insert(path.to_string(), (status, body));
5494        };
5495        set("/jwks", "200 OK", jwks_body());
5496        let v = validator_no_cooldown(&server.url);
5497        assert!(v.validate(&valid_token()).await.is_ok());
5498        for (body, kind) in [
5499            ("not json".to_string(), RefreshErrorKind::Parse),
5500            ("{}".to_string(), RefreshErrorKind::Parse),
5501            (
5502                jwks_of(&[serde_json::json!({"kty": "oct", "k": "c2VjcmV0"})]),
5503                RefreshErrorKind::NoUsableKeys,
5504            ),
5505        ] {
5506            set("/jwks", "200 OK", body.clone());
5507            assert_eq!(v.refresh_now().await.unwrap_err().kind(), kind, "{body}");
5508            assert_eq!(v.key_set_status().keys, 1, "{body}");
5509            assert!(v.validate(&valid_token()).await.is_ok(), "{body}");
5510        }
5511
5512        // Discovery: keys loaded through the metadata, then the metadata and
5513        // the key set both break. The discovered URI is dropped after the
5514        // failed fetch, and the re-discovery that follows fails too.
5515        let issuer = format!("{}/app/", server.base);
5516        const METADATA: &str = "/app/.well-known/openid-configuration";
5517        set(
5518            METADATA,
5519            "200 OK",
5520            serde_json::json!({"issuer": issuer, "jwks_uri": format!("{}/keys", server.base)})
5521                .to_string(),
5522        );
5523        set("/keys", "200 OK", jwks_body());
5524        let mut cfg = oauth_config("");
5525        cfg.issuer = issuer.clone();
5526        let v = OAuthValidator::build(&cfg, Duration::ZERO).unwrap();
5527        let token = mint(
5528            KEY_A_PEM,
5529            KID_A,
5530            &claims(serde_json::json!({"iss": issuer, "scope": "mcp:read"})),
5531        );
5532        assert_eq!(v.refresh_now().await.unwrap(), 1);
5533        assert!(v.validate(&token).await.is_ok());
5534        set(METADATA, "404 Not Found", "{}".into());
5535        set("/keys", "404 Not Found", "{}".into());
5536        assert_eq!(
5537            v.refresh_now().await.unwrap_err().kind(),
5538            RefreshErrorKind::Fetch
5539        );
5540        assert_eq!(
5541            v.refresh_now().await.unwrap_err().kind(),
5542            RefreshErrorKind::Discovery
5543        );
5544        assert_eq!(v.key_set_status().keys, 1);
5545        assert!(v.is_ready());
5546        // The counter only moves on success: prove the keys themselves are
5547        // still there by verifying with them.
5548        assert!(v.validate(&token).await.is_ok());
5549    }
5550}