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::{debug, info, warn};
13
14use crate::algorithms::Algorithm;
15use crate::challenge;
16use crate::config::ResolvedOAuthConfig;
17use crate::jwks::{
18    JWKS_BACKGROUND_REFRESH_INTERVAL, JWKS_MIN_REFETCH_INTERVAL, JwksStore, RefreshError,
19    background_retry_delay, http_client,
20};
21use crate::token::{
22    AuthorizedToken, MAX_TOKEN_BYTES, TokenRejection, check_typ, extract_principal, extract_scopes,
23    for_log,
24};
25
26/// Why an [`OAuthValidator`] could not be built.
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum ValidatorError {
30    /// Neither `audience` nor `audiences` holds a value. [`crate::OAuthConfig::resolve`]
31    /// refuses this; only a hand-edited [`ResolvedOAuthConfig`] reaches it.
32    #[error("{section}: no accepted audience configured")]
33    #[non_exhaustive]
34    NoAudience {
35        /// The config block, named per its [`crate::KeyNaming`].
36        section: String,
37    },
38    /// The algorithm allowlist is empty. Refused by `resolve` as well.
39    #[error("{key} is empty")]
40    #[non_exhaustive]
41    NoAlgorithms {
42        /// The `algorithms` setting, named per its [`crate::KeyNaming`].
43        key: String,
44    },
45    /// `leeway_secs` is over [`crate::MAX_LEEWAY_SECS`]. Refused by `resolve`
46    /// as well; re-checked because a larger leeway silently extends every
47    /// token's life (and past the current Unix time, overflows the expiry
48    /// arithmetic).
49    #[error("{key} {leeway_secs} is over the {max}-second cap")]
50    #[non_exhaustive]
51    LeewayTooLarge {
52        /// The `leeway_secs` setting, named per its [`crate::KeyNaming`].
53        key: String,
54        /// The configured value.
55        leeway_secs: u64,
56        /// [`crate::MAX_LEEWAY_SECS`].
57        max: u64,
58    },
59    /// The HTTP client for metadata/JWKS fetches could not be built (in
60    /// practice: the TLS backend failed to initialize).
61    ///
62    /// The underlying error is boxed rather than named, so the HTTP client
63    /// library's version is not part of this crate's public API; it is still
64    /// reachable through [`std::error::Error::source`].
65    #[error("Failed to build the HTTP client for OAuth metadata/JWKS fetches")]
66    HttpClient(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
67}
68
69/// The outcome of a cache-only validation attempt (`OAuthValidator::validate_cached`).
70pub(crate) enum CachedAttempt {
71    /// Decided without any key fetch: accepted, or refused for a reason a
72    /// fetch could not change.
73    Decided(Result<AuthorizedToken, TokenRejection>),
74    /// The header checks passed but no key already held fits `kid`/`alg`;
75    /// only a full `validate` (which may refetch the JWKS) can decide it.
76    NeedsKeyFetch,
77}
78
79/// Validates bearer credentials as JWT access tokens (RFC 9068) for one resource.
80///
81/// Built once from a [`ResolvedOAuthConfig`] and shared (`Arc`) for the life of
82/// the process; nothing about it hot-reloads. It never issues, refreshes, revokes
83/// or introspects tokens, and it only talks to the authorization server to fetch
84/// its metadata (when no `jwks_uri` is configured) and its public signing keys.
85///
86/// Every check that can be made from the unverified header (size, JWS shape,
87/// `crit`, `alg` allowlist, `typ`) runs before any key is fetched, so junk
88/// cannot schedule IdP traffic. Signature, `iss`, `aud`, `exp` and `nbf` are all
89/// checked inside one `jsonwebtoken::decode`, so the claim checks can never be
90/// reordered ahead of the signature. Every failure fails closed.
91///
92/// # Runtime
93///
94/// Validation, [`OAuthValidator::refresh_now`] and
95/// [`OAuthValidator::spawn_background_refresh`] need a Tokio 1.x runtime: key
96/// fetches use `reqwest` (with a Tokio timer) and run in a spawned task. Called
97/// outside one, the first key fetch panics. On `async-std`, `smol` or another
98/// executor, drive them from a Tokio runtime handle.
99///
100/// # Extension point: opaque tokens
101///
102/// Opaque (non-JWT) access tokens are refused today. RFC 7662 introspection would
103/// cover them but needs a client credential and per-request AS round trips, so it
104/// is deliberately not built. An introspection backend would be a feature-gated
105/// alternative to the JWKS key source, chosen at construction, and
106/// [`OAuthValidator::validate`] would dispatch to it where it now refuses a
107/// non-JWT credential. Its result is the same [`AuthorizedToken`] (and
108/// [`TokenRejection`]), both `#[non_exhaustive]`, so code consuming a validation
109/// result is unaffected; [`ResolvedOAuthConfig`] is `#[non_exhaustive]` too, so a
110/// new resolved setting is an additive change.
111///
112/// [`crate::OAuthConfig`] is deliberately NOT `#[non_exhaustive]`: applications
113/// build it with a functional-record update (`OAuthConfig { enabled: true,
114/// ..OAuthConfig::default() }`), which that attribute would forbid outside
115/// this crate, and which keeps compiling even after a field is added. Adding
116/// introspection keys to it (endpoint, client credential) would instead break
117/// only an exhaustive struct literal or destructuring pattern that names
118/// every field — possible today because every field is public — and would
119/// still ship in a new `0.x` minor release, which Cargo already treats as
120/// incompatible — unless those settings are passed to a separate constructor
121/// instead, which leaves `OAuthConfig` untouched.
122pub struct OAuthValidator {
123    /// Everything below is derived from this once; it is kept for scope and
124    /// claim policy and for logging.
125    config: ResolvedOAuthConfig,
126    /// Pre-rendered so the 401/403 paths are a string clone, not a `format!` per
127    /// rejected request.
128    resource_metadata_url: String,
129    /// The route the metadata document must be served on (the path of
130    /// `resource_metadata_url`).
131    metadata_path: String,
132    /// `scopes_supported`, space-joined, for the 401 challenge's `scope` parameter.
133    supported_scopes: String,
134    /// `required_scopes`, space-joined, for the 403 challenge and the log line.
135    required_scopes: String,
136    /// The RFC 9728 document, rendered once.
137    metadata: Value,
138    /// Allowlisted algorithms in the JWT library's form, for the header check.
139    jwt_algorithms: Vec<jsonwebtoken::Algorithm>,
140    /// Issuer/audience/expiry/not-before policy, built once. Each token gets a
141    /// clone with `algorithms` narrowed to its own (already allowlisted and
142    /// key-compatible) `alg`, because `jsonwebtoken` refuses a `Validation` whose
143    /// algorithms span more than one key family. The claim checks all run inside
144    /// `decode`, which is what keeps signature verification and claim validation
145    /// from being two separately-forgettable steps.
146    validation: Validation,
147    /// The signing keys: cache, discovery, refresh and rate limiting. Shared
148    /// with the detached tasks that run its fetches.
149    keys: Arc<JwksStore>,
150    /// Dropped with the validator, which is how the background refresh task
151    /// learns to stop: it waits on a receiver whose `changed()` resolves once
152    /// this sender is gone.
153    alive: tokio::sync::watch::Sender<()>,
154}
155
156impl std::fmt::Debug for OAuthValidator {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("OAuthValidator")
159            .field("issuer", &self.config.issuer)
160            .field("resource", &self.config.resource)
161            .field("required_scopes", &self.config.required_scopes)
162            .finish_non_exhaustive()
163    }
164}
165
166impl OAuthValidator {
167    /// Build a validator. Does no I/O: keys are fetched on first use, or earlier
168    /// by [`OAuthValidator::spawn_background_refresh`] /
169    /// [`OAuthValidator::refresh_now`].
170    ///
171    /// Build one per process and share it (`Arc`): it owns the key cache, so
172    /// separate validators would each fetch and refresh their own keys.
173    ///
174    /// Logs a `warn` for a configuration that works but is weaker than it
175    /// probably should be: a required scope missing from `scopes_supported`
176    /// (clients that request the advertised scopes will get 403), no required
177    /// scope with `require_at_jwt` off (ID tokens for the same client are
178    /// accepted), and a plain-`http` issuer, `jwks_uri` or resource on a
179    /// non-loopback host. [`crate::OAuthConfig::resolve`] refuses the last two
180    /// unless the config opts in explicitly; the warning is for the deployments
181    /// that did.
182    ///
183    /// # Errors
184    ///
185    /// [`ValidatorError`] when the config has no accepted audience, no
186    /// algorithm, or a `leeway_secs` over [`crate::MAX_LEEWAY_SECS`] (none of
187    /// which a config from [`crate::OAuthConfig::resolve`] can have, but the
188    /// fields of [`ResolvedOAuthConfig`] are public), or when the HTTP client
189    /// for key fetches cannot be built (the TLS backend failed to initialize).
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use std::sync::Arc;
195    ///
196    /// use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};
197    ///
198    /// let resolved = OAuthConfig {
199    ///     enabled: true,
200    ///     issuer: "https://auth.example.com/".into(),
201    ///     audience: "example-api".into(),
202    ///     resource: "https://api.example.com/v1".into(),
203    ///     required_scope: Some("api:read".into()),
204    ///     scopes_supported: Some(vec!["api:read".into()]),
205    ///     ..OAuthConfig::default()
206    /// }
207    /// .resolve(KeyNaming::Dotted("oauth"))
208    /// .unwrap()
209    /// .unwrap();
210    ///
211    /// let validator = Arc::new(OAuthValidator::new(&resolved).unwrap());
212    /// assert_eq!(
213    ///     validator.metadata_path(),
214    ///     "/.well-known/oauth-protected-resource/v1"
215    /// );
216    /// assert_eq!(
217    ///     validator.insufficient_scope_challenge(),
218    ///     "Bearer error=\"insufficient_scope\", scope=\"api:read\", \
219    ///      resource_metadata=\"https://api.example.com/.well-known/oauth-protected-resource/v1\""
220    /// );
221    /// // In a server, inside the tokio runtime:
222    /// // validator.spawn_background_refresh();
223    /// ```
224    pub fn new(config: &ResolvedOAuthConfig) -> Result<Self, ValidatorError> {
225        Self::build(config, JWKS_MIN_REFETCH_INTERVAL)
226    }
227
228    pub(crate) fn build(
229        config: &ResolvedOAuthConfig,
230        jwks_min_refetch_interval: Duration,
231    ) -> Result<Self, ValidatorError> {
232        let naming = &config.key_naming;
233        // `OAuthConfig::resolve` already refuses all three of these; re-checked
234        // here because `ResolvedOAuthConfig`'s fields are public and may be
235        // adjusted after resolving. An empty audience set or algorithm list is
236        // the construction mistake that would fail OPEN-adjacent (an empty `aud`
237        // set in jsonwebtoken means "reject everything", but an empty allowlist
238        // is a panic-free foot-gun nobody should have to reason about), and an
239        // oversized leeway silently extends every token's life — jsonwebtoken
240        // computes `now - leeway` unchecked, so past `now` it also overflows.
241        let audiences = config.accepted_audiences();
242        if audiences.is_empty() {
243            return Err(ValidatorError::NoAudience {
244                section: naming.section(),
245            });
246        }
247        let Some(&first_alg) = config.algorithms.first() else {
248            return Err(ValidatorError::NoAlgorithms {
249                key: naming.key("algorithms"),
250            });
251        };
252        if config.leeway_secs > crate::config::MAX_LEEWAY_SECS {
253            return Err(ValidatorError::LeewayTooLarge {
254                key: naming.key("leeway_secs"),
255                leeway_secs: config.leeway_secs,
256                max: crate::config::MAX_LEEWAY_SECS,
257            });
258        }
259
260        let mut validation = Validation::new(first_alg.to_jwt());
261        // Byte-exact issuer match. Authentik's issuer ends in a slash and the
262        // difference matters — `.../example-app/` and `.../example-app` are
263        // different strings and only one of them is in the tokens.
264        validation.set_issuer(&[&config.issuer]);
265        // Membership, per RFC 7519 §4.1.3: `aud` may be a string or an array, and
266        // the token is accepted if ANY element is one of the configured audiences.
267        // What those audiences should be is provider-specific and deliberately
268        // config, never guessed — the client_id on servers that ignore RFC 8707
269        // (Authentik, Kanidm), the resource URL on servers configured to stamp it
270        // (Authelia with a client `audience`). See `OAuthConfig::audience`.
271        validation.set_audience(&audiences);
272        // `jsonwebtoken` only validates `iss`/`aud` when the claim is *present*, so
273        // requiring them here is what turns "wrong issuer" and "no issuer at all"
274        // into the same refusal. Without this a token carrying neither claim would
275        // sail through both checks.
276        validation.set_required_spec_claims(&["exp", "iss", "aud"]);
277        validation.leeway = config.leeway_secs;
278        validation.validate_exp = true;
279        // Off by default in jsonwebtoken. RFC 9068 tokens (Authelia, Kanidm) carry
280        // `nbf`; a token presented before it is not yet valid. jsonwebtoken skips
281        // an `nbf` it cannot read as a number, so `verify` refuses one of those
282        // itself (`nbf_is_numeric_date`).
283        validation.validate_nbf = true;
284        validation.validate_aud = true;
285
286        let resource_metadata_url = challenge::resource_metadata_url(&config.resource);
287        let metadata_path = challenge::metadata_path(&resource_metadata_url);
288        let required_scopes = config.required_scopes.join(" ");
289        // The 401's `scope` names what to ask for: the advertised menu, or —
290        // when the config advertises none — what is required, so a client is
291        // never left to request nothing and be refused with 403.
292        let supported_scopes = if config.scopes_supported.is_empty() {
293            required_scopes.clone()
294        } else {
295            config.scopes_supported.join(" ")
296        };
297
298        // A required scope nobody is told to ask for is a guaranteed 403 for every
299        // client that requests exactly `scopes_supported`. Not fatal — an operator
300        // may be advertising a narrower menu on purpose — but never silent. An
301        // empty `scopes_supported` is not that case: the challenge then names
302        // the required scopes itself (`supported_scopes` above), so a client is
303        // told exactly what to request.
304        let unadvertised = unadvertised_scopes(config);
305        if !unadvertised.is_empty() {
306            // Names the scopes rather than a setting: `required_scopes` is the union
307            // of `required_scope` and `required_scopes`, and which of the two an
308            // unadvertised scope came from is not known here.
309            warn!(
310                unadvertised_scopes = %unadvertised.join(" "),
311                scopes_supported = ?config.scopes_supported,
312                "required scope(s) {} not in {} — clients that request the advertised \
313                 scopes will get 403 insufficient_scope",
314                unadvertised.join(" "),
315                naming.key("scopes_supported")
316            );
317        }
318        // Valid by design (an application may need only "signed by this issuer for
319        // this audience"), but a weaker posture than a scoped deployment, so it is
320        // said once at construction rather than left implicit. With `typ` not
321        // enforced either, nothing tells an access token from an ID token minted
322        // for the same client: on servers that stamp the client_id as `aud`
323        // (Authentik, Kanidm) the ID token a front end got from an OIDC login is
324        // then a working bearer credential. That combination is a warning.
325        match unscoped_posture(config) {
326            UnscopedPosture::Scoped => {}
327            UnscopedPosture::UnscopedButTypEnforced => info!(
328                "no required scope configured ({} and {} unset) — every valid access \
329                 token (typ at+jwt) for the audience is accepted",
330                naming.key("required_scope"),
331                naming.key("required_scopes")
332            ),
333            UnscopedPosture::IdTokensAccepted => warn!(
334                "no required scope configured ({} and {} unset) and {} is off — ANY token \
335                 this issuer signs for the audience is accepted, including an OIDC ID token \
336                 minted for the same client. Set {} (a scope only access tokens carry) or \
337                 turn on {} if the authorization server emits typ at+jwt.",
338                naming.key("required_scope"),
339                naming.key("required_scopes"),
340                naming.key("require_at_jwt"),
341                naming.key("required_scope"),
342                naming.key("require_at_jwt")
343            ),
344        }
345        if plain_http_non_loopback(&config.issuer) {
346            warn!(
347                issuer = %config.issuer,
348                "{} uses plain http on a non-loopback host — signing keys fetched over it \
349                 can be substituted by anyone on the path. Use https.",
350                naming.key("issuer")
351            );
352        }
353        if plain_http_non_loopback(&config.resource) {
354            warn!(
355                resource = %config.resource,
356                "{} uses plain http on a non-loopback host — bearer tokens sent to it can \
357                 be read in transit. Use https.",
358                naming.key("resource")
359            );
360        }
361        if let Some(jwks_uri) = config.jwks_uri.as_deref().map(str::trim)
362            && plain_http_non_loopback(jwks_uri)
363        {
364            // The discovered-URI path refuses this outright when the issuer is
365            // https, and without `allow_insecure_http` otherwise
366            // (`jwks_uri_from_metadata`; `refresh` warns when the opt-in lets
367            // one through). A configured one reaches here only
368            // with `allow_insecure_http` (or a hand-edited resolved config) — an
369            // in-cluster `http://idp:9000/...` behind a private network is a real
370            // deployment shape — so it is warned about, never silent.
371            warn!(
372                jwks_uri = %jwks_uri,
373                "{} uses plain http on a non-loopback host — signing keys fetched over it \
374                 can be substituted by anyone on the path. Use https.",
375                naming.key("jwks_uri")
376            );
377        }
378
379        let metadata = challenge::metadata_document(config);
380        let http = http_client(
381            config.allow_insecure_http,
382            naming.key("allow_insecure_http"),
383        )
384        .map_err(|e| ValidatorError::HttpClient(Box::new(e)))?;
385
386        Ok(Self {
387            config: config.clone(),
388            resource_metadata_url,
389            metadata_path,
390            supported_scopes,
391            required_scopes,
392            metadata,
393            jwt_algorithms: config.algorithms.iter().map(|a| a.to_jwt()).collect(),
394            validation,
395            keys: Arc::new(JwksStore::new(config, http, jwks_min_refetch_interval)),
396            alive: tokio::sync::watch::channel(()).0,
397        })
398    }
399
400    /// The config this validator was built from.
401    pub fn config(&self) -> &ResolvedOAuthConfig {
402        &self.config
403    }
404
405    /// The protected resource's identifier ([`crate::OAuthConfig::resource`]).
406    pub fn resource(&self) -> &str {
407        &self.config.resource
408    }
409
410    /// The protected-resource metadata URL advertised in every challenge's
411    /// `resource_metadata` parameter (RFC 9728 §3: the well-known segment spliced
412    /// between the resource's authority and path).
413    pub fn resource_metadata_url(&self) -> &str {
414        &self.resource_metadata_url
415    }
416
417    /// The path of [`OAuthValidator::resource_metadata_url`] — the route the
418    /// metadata document must be served on, e.g.
419    /// `/.well-known/oauth-protected-resource/mcp` for a resource at `/mcp`, or
420    /// the bare [`crate::PROTECTED_RESOURCE_METADATA_PREFIX`] for a resource at
421    /// the root.
422    ///
423    /// It comes from config and may contain characters a router reads as
424    /// pattern syntax (`{…}`, or a segment starting with `:` or `*`, which axum
425    /// refuses with a panic), so an app serving it itself should compare the
426    /// request path against it literally rather than register it as a route.
427    /// The `axum` feature's `metadata_router` does exactly that.
428    pub fn metadata_path(&self) -> &str {
429        &self.metadata_path
430    }
431
432    /// The RFC 9728 protected-resource metadata document, rendered once at
433    /// construction. `scopes_supported` is left out when the list is empty
434    /// (RFC 9728 §3.2: a parameter with zero values is omitted).
435    pub fn metadata(&self) -> &Value {
436        &self.metadata
437    }
438
439    /// The `WWW-Authenticate` value for every 401 — a refused credential and, by
440    /// deliberate choice, a missing one too:
441    /// `Bearer error="invalid_token", resource_metadata="…", scope="…"`.
442    /// `scope` lists `scopes_supported`, or the required scopes when nothing is
443    /// advertised (the MCP authorization spec asks servers to name the scopes
444    /// needed here). With neither, the parameter is omitted, not sent empty:
445    /// RFC 6749 §3.3 requires at least one scope-token.
446    ///
447    /// Load-bearing, not cosmetic: claude.ai has been observed refusing to start
448    /// the authorization flow at all when a 401 arrives without it, because
449    /// `resource_metadata` is how the client finds the authorization server in the
450    /// first place. Claude Code tolerates its absence, which is exactly why it is
451    /// easy to drop and hard to notice. Emit it on EVERY 401 once OAuth is
452    /// configured — including a failed static-token request, since the server
453    /// cannot tell which credential the caller meant to present.
454    pub fn invalid_token_challenge(&self) -> String {
455        challenge::invalid_token(&self.resource_metadata_url, &self.supported_scopes)
456    }
457
458    /// The `WWW-Authenticate` value for a 403:
459    /// `Bearer error="insufficient_scope", scope="…", resource_metadata="…"`.
460    ///
461    /// The token was genuinely valid, so `scope` names what is *required* —
462    /// every required scope, space-delimited (RFC 6750 §3) — rather than
463    /// everything on offer. That is the difference that lets a client
464    /// re-authorize for the right thing instead of replaying the same request.
465    /// With no required scope (no token is ever refused for scope) the `scope`
466    /// parameter is omitted.
467    pub fn insufficient_scope_challenge(&self) -> String {
468        challenge::insufficient_scope(&self.required_scopes, &self.resource_metadata_url)
469    }
470
471    /// Validate a bearer credential as a JWT access token.
472    ///
473    /// Order matters and is RFC 9068 §4's: everything that can be refused from the
474    /// unverified header alone (size, shape, `alg` allowlist, `typ`) is refused
475    /// before any key is fetched, so junk cannot schedule IdP traffic; then the
476    /// signature; then issuer / audience / expiry / not-before — all inside
477    /// `jsonwebtoken::decode`, so they cannot be reordered ahead of the signature by
478    /// accident — then scope: the token must carry EVERY required scope.
479    ///
480    /// `token` is the credential alone, without the `Bearer ` prefix. When the
481    /// signing key is not cached this fetches the JWKS (at most once a minute
482    /// for an unknown `kid`), so the call can wait on a fetch, each bounded by
483    /// a 10-second timeout. The fetch runs in a task of its own, so dropping
484    /// this future does not cancel it. Logs an insufficient scope at `info` and
485    /// a failed key refresh at `warn`; logging the outcome is the caller's job.
486    ///
487    /// # Errors
488    ///
489    /// - [`TokenRejection::Missing`] for an empty `token`.
490    /// - [`TokenRejection::Invalid`] for everything that makes the token no
491    ///   good: over 16 KiB, not a JWT, an unparsable header, a header listing
492    ///   critical extensions (`crit`, RFC 7515 §4.1.11: this crate supports
493    ///   none), an `alg` outside the allowlist, a refused `typ`, no usable key,
494    ///   a bad signature, a wrong or missing `iss`/`aud`, an expired or
495    ///   not-yet-valid token, an `nbf` that is not a NumericDate, or a
496    ///   sender-constrained token (a `cnf` claim: DPoP, RFC 9449 §7.2, or
497    ///   mTLS, RFC 8705 §3), which this crate cannot verify the binding of and
498    ///   so will not accept as a plain bearer token.
499    /// - [`TokenRejection::InsufficientScope`] for a valid token that lacks a
500    ///   required scope.
501    ///
502    /// # Panics
503    ///
504    /// Outside a Tokio 1.x runtime, when a key has to be fetched (see
505    /// [Runtime](OAuthValidator#runtime)).
506    ///
507    /// # Security
508    ///
509    /// The reason inside `Invalid` names the check that failed. Log it; never
510    /// send it to the caller, for whom it would be an oracle. Answer with
511    /// [`OAuthValidator::invalid_token_challenge`] or
512    /// [`OAuthValidator::insufficient_scope_challenge`] instead.
513    ///
514    /// # Examples
515    ///
516    /// ```no_run
517    /// use oauth_resource_server::{OAuthValidator, TokenRejection};
518    ///
519    /// /// The status and `WWW-Authenticate` value for a request.
520    /// async fn check(validator: &OAuthValidator, bearer: &str) -> (u16, Option<String>) {
521    ///     match validator.validate(bearer).await {
522    ///         Ok(token) => {
523    ///             println!("accepted {:?} with scopes {:?}", token.subject, token.scopes);
524    ///             (200, None)
525    ///         }
526    ///         Err(TokenRejection::InsufficientScope) => {
527    ///             (403, Some(validator.insufficient_scope_challenge()))
528    ///         }
529    ///         Err(rejection) => {
530    ///             eprintln!("refused: {rejection:?}"); // for the log only
531    ///             (401, Some(validator.invalid_token_challenge()))
532    ///         }
533    ///     }
534    /// }
535    /// ```
536    pub async fn validate(&self, token: &str) -> Result<AuthorizedToken, TokenRejection> {
537        let header = self.check_header(token)?;
538        let key = self
539            .keys
540            .decoding_key(header.kid.as_deref(), header.alg)
541            .await?;
542        self.verify(token, header.alg, &key)
543    }
544
545    /// [`OAuthValidator::validate`] against the keys already held, never
546    /// fetching: [`CachedAttempt::NeedsKeyFetch`] when every header check passed
547    /// but no cached key fits. Everything else — header refusals, signature and
548    /// claim checks, scope — is exactly `validate`'s, in the same order.
549    ///
550    /// [`crate::authenticate()`] runs this over every candidate first, so a
551    /// candidate whose key is cached is decided before any other candidate's
552    /// unknown `kid` can queue the request behind a JWKS refetch.
553    pub(crate) async fn validate_cached(&self, token: &str) -> CachedAttempt {
554        let header = match self.check_header(token) {
555            Ok(header) => header,
556            Err(rejection) => return CachedAttempt::Decided(Err(rejection)),
557        };
558        match self
559            .keys
560            .cached_decoding_key(header.kid.as_deref(), header.alg)
561            .await
562        {
563            Some(key) => CachedAttempt::Decided(self.verify(token, header.alg, &key)),
564            None => CachedAttempt::NeedsKeyFetch,
565        }
566    }
567
568    /// Everything that can be refused from the unverified header alone (size,
569    /// shape, `crit`, `alg` allowlist, `typ`), before any key is looked up.
570    fn check_header(&self, token: &str) -> Result<CheckedHeader, TokenRejection> {
571        if token.is_empty() {
572            return Err(TokenRejection::Missing);
573        }
574        if token.len() > MAX_TOKEN_BYTES {
575            return Err(TokenRejection::Invalid(format!(
576                "credential is {} bytes, over the {MAX_TOKEN_BYTES}-byte cap",
577                token.len()
578            )));
579        }
580        if token.split('.').count() != 3 {
581            // The single most useful hint in this crate for a new deployment:
582            // Authelia (by default), Ory Hydra (by default) and others issue OPAQUE
583            // access tokens, which no amount of JWKS can verify. (This is where an
584            // RFC 7662 introspection backend would take over; see the type docs.)
585            return Err(TokenRejection::Invalid(
586                "credential is not a JWT (a mistyped static token, or an opaque access \
587                 token — this server validates JWT access tokens only; configure the \
588                 authorization server to issue JWT access tokens)"
589                    .into(),
590            ));
591        }
592
593        // The header is unverified data. It is read only to pick which key to
594        // verify WITH; nothing from it is trusted afterwards, and `alg` is checked
595        // against our allowlist (and later against the key) rather than obeyed.
596        // `alg: none` does not even get this far: jsonwebtoken's `Algorithm` has no
597        // `none` variant, so the header fails to parse.
598        // The error text can echo attacker-supplied header content (an unknown
599        // `alg` string, verbatim), so it is truncated like every other
600        // token-derived string that reaches a log line.
601        let header = decode_header(token).map_err(|e| {
602            TokenRejection::Invalid(format!(
603                "malformed token header: {}",
604                for_log(&e.to_string())
605            ))
606        })?;
607        check_crit(token)?;
608        let alg = Algorithm::from_jwt(header.alg)
609            .filter(|_| self.jwt_algorithms.contains(&header.alg))
610            .ok_or_else(|| {
611                TokenRejection::Invalid(format!(
612                    "token algorithm {:?} is not in {}",
613                    header.alg,
614                    self.config.key_naming.key("algorithms")
615                ))
616            })?;
617        check_typ(
618            header.typ.as_deref(),
619            self.config.require_at_jwt,
620            &self.config.key_naming,
621        )?;
622        Ok(CheckedHeader {
623            kid: header.kid,
624            alg,
625        })
626    }
627
628    /// Signature, then issuer / audience / expiry / not-before, then the
629    /// claims the decoder does not police (`iss` shape, `nbf` type, `cnf`),
630    /// then scope, against `key` — which [`OAuthValidator::check_header`]'s
631    /// output selected.
632    fn verify(
633        &self,
634        token: &str,
635        alg: Algorithm,
636        key: &DecodingKey,
637    ) -> Result<AuthorizedToken, TokenRejection> {
638        let mut validation = self.validation.clone();
639        validation.algorithms = vec![alg.to_jwt()];
640        let data = decode::<Map<String, Value>>(token, key, &validation).map_err(|e| {
641            // `jsonwebtoken`'s error kinds already distinguish bad signature from
642            // bad issuer/audience/expiry; all of them are 401 `invalid_token` to the
643            // caller, and only the log gets to know which.
644            TokenRejection::Invalid(format!("token rejected: {e}"))
645        })?;
646        let claims = data.claims;
647
648        // Belt and braces on `iss`: jsonwebtoken also accepts an `iss` ARRAY that
649        // merely contains the configured issuer. RFC 7519 makes `iss` a single
650        // StringOrURI, and "one of several issuers" is not a shape any real AS
651        // emits, so anything but the exact string is refused.
652        if claims.get("iss").and_then(Value::as_str) != Some(self.config.issuer.as_str()) {
653            return Err(TokenRejection::Invalid(format!(
654                "token iss is not a single string equal to {}",
655                self.config.key_naming.key("issuer")
656            )));
657        }
658
659        // RFC 7519 §4.1.5: `nbf` is a NumericDate, and the token MUST NOT be
660        // accepted before it. jsonwebtoken checks it only when it reads as a
661        // number and silently skips anything else (a string, a negative or
662        // out-of-range value), which would turn a not-yet-valid token into a
663        // valid one. Anything it could not have checked is refused here.
664        if let Some(nbf) = claims.get("nbf")
665            && !nbf_is_numeric_date(nbf)
666        {
667            return Err(TokenRejection::Invalid(
668                "token nbf is not a NumericDate (a non-negative number of seconds)".into(),
669            ));
670        }
671
672        // A `cnf` (confirmation) claim binds the token to a key the client must
673        // prove it holds: DPoP (RFC 9449, `jkt`) or an mTLS certificate (RFC
674        // 8705, `x5t#S256`). This crate verifies neither proof, so accepting the
675        // token as a plain bearer token would undo the binding the
676        // authorization server set up — exactly what RFC 9449 §7.2 and RFC 8705
677        // §3 forbid a resource server to do.
678        if claims.contains_key("cnf") {
679            return Err(TokenRejection::Invalid(
680                "token is sender-constrained (cnf); this server accepts bearer tokens only".into(),
681            ));
682        }
683
684        let scopes = extract_scopes(&claims, &self.config.scope_claims);
685        let principal = extract_principal(&claims, &self.config.principal_claims);
686        let subject = claims
687            .get("sub")
688            .and_then(Value::as_str)
689            .map(str::to_string);
690
691        // All-of: every required scope must be present. An empty requirement
692        // passes every token.
693        if !self
694            .config
695            .required_scopes
696            .iter()
697            .all(|required| scopes.contains(required))
698        {
699            // Info, not debug: this is the refusal an operator wiring up a new
700            // authorization server hits first (Authelia's `scp`-only tokens were
701            // exactly this), and `present=[]` next to the claims that were
702            // read is most of the diagnosis. Scopes are not secret.
703            info!(
704                principal = ?principal.as_deref().map(for_log),
705                required = %self.required_scopes,
706                present = ?scopes,
707                scope_claims = ?self.config.scope_claims,
708                "OAuth token is valid but lacks the required scope"
709            );
710            return Err(TokenRejection::InsufficientScope);
711        }
712
713        Ok(AuthorizedToken {
714            subject,
715            principal,
716            scopes,
717        })
718    }
719
720    /// Load (or reload) the key set now, discovering the JWKS URI first if needed.
721    /// Returns how many usable keys it holds. On failure the previous keys are
722    /// kept — a transient IdP outage must not invalidate keys that are still good.
723    ///
724    /// Useful for a startup check that waits for the keys (a readiness probe,
725    /// or a test); [`OAuthValidator::spawn_background_refresh`] already calls it
726    /// once at startup and then hourly. The fetch runs in a task of its own,
727    /// so dropping this future does not cancel it.
728    ///
729    /// # Errors
730    ///
731    /// [`RefreshError`] when discovery fails (no metadata document, or one for
732    /// a different issuer), the JWKS cannot be fetched (network, TLS, status,
733    /// size cap, not JSON), or the key set holds no key usable with the
734    /// configured algorithms. Its `Display` includes the whole cause chain.
735    ///
736    /// # Panics
737    ///
738    /// Outside a Tokio 1.x runtime (see [Runtime](OAuthValidator#runtime)).
739    pub async fn refresh_now(&self) -> Result<usize, RefreshError> {
740        self.keys.refresh_now().await
741    }
742
743    /// Warm the key cache at startup and keep it fresh; returns the task's handle.
744    ///
745    /// The first pass turns a misconfigured issuer, an unreachable JWKS or a
746    /// discovery mismatch into one clear log line at boot instead of a wall of
747    /// 401s on the first real request — without making startup itself depend on
748    /// the authorization server being up (a restart during an IdP outage must not
749    /// take this service down too). Later passes, hourly, are what drop a key the
750    /// AS has withdrawn — once one succeeds: a failed pass keeps every key held,
751    /// and is retried after a minute, backing off to an hour.
752    ///
753    /// The first load logs `OAuth: authorization server signing keys loaded` at
754    /// `info`, or `OAuth: could not load the authorization server's signing
755    /// keys` at `warn`. The task holds only a weak reference between passes: it
756    /// stops once the last `Arc` of this validator is dropped (or when the
757    /// returned handle is aborted), so rebuilding a validator does not leave the
758    /// old one polling. Dropping the handle alone does not stop it.
759    ///
760    /// # Panics
761    ///
762    /// When called outside a Tokio 1.x runtime (it uses `tokio::spawn`).
763    pub fn spawn_background_refresh(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
764        let weak = Arc::downgrade(self);
765        let mut alive = self.alive.subscribe();
766        tokio::spawn(async move {
767            let mut first = true;
768            let mut failures: u32 = 0;
769            loop {
770                let Some(this) = weak.upgrade() else {
771                    return;
772                };
773                let wait = match this.refresh_now().await {
774                    Ok(count) => {
775                        if first {
776                            info!(
777                                issuer = %this.config.issuer,
778                                keys = count,
779                                "OAuth: authorization server signing keys loaded"
780                            );
781                        } else {
782                            debug!(keys = count, "OAuth: signing keys refreshed");
783                        }
784                        failures = 0;
785                        JWKS_BACKGROUND_REFRESH_INTERVAL
786                    }
787                    Err(e) => {
788                        failures = failures.saturating_add(1);
789                        let wait = background_retry_delay(failures);
790                        warn!(
791                            issuer = %this.config.issuer,
792                            error = %e,
793                            retry_in_secs = wait.as_secs(),
794                            "OAuth: could not load the authorization server's signing keys — \
795                             tokens signed by a key this server does not already hold will be \
796                             rejected (401) until a later attempt succeeds. Check {} / {} and \
797                             that this host can reach them.",
798                            this.config.key_naming.key("issuer"),
799                            this.config.key_naming.key("jwks_uri")
800                        );
801                        wait
802                    }
803                };
804                first = false;
805                // Only the weak reference survives the wait, so the validator
806                // can be dropped meanwhile — which drops `alive`'s sender and
807                // ends the wait at once.
808                drop(this);
809                if tokio::time::timeout(wait, alive.changed()).await.is_ok() {
810                    return;
811                }
812            }
813        })
814    }
815}
816
817/// The header fields a validation carries forward: the `kid` to look the key
818/// up by, and the allowlisted algorithm.
819struct CheckedHeader {
820    kid: Option<String>,
821    alg: Algorithm,
822}
823
824/// RFC 7515 §4.1.11: a recipient that does not understand every extension a
825/// JWS lists in `crit` MUST treat it as invalid. This crate understands none,
826/// so any `crit` — including an empty or malformed one — is refused.
827/// jsonwebtoken's `Header` has no `crit` field and drops it silently, so the
828/// protected header is read raw here. Called after `decode_header` succeeded,
829/// so the segment is known to be base64url JSON; it is at most the 16 KiB
830/// credential cap.
831fn check_crit(token: &str) -> Result<(), TokenRejection> {
832    let segment = token.split('.').next().unwrap_or_default();
833    let header: Map<String, Value> = URL_SAFE_NO_PAD
834        .decode(segment)
835        .ok()
836        .and_then(|raw| serde_json::from_slice(&raw).ok())
837        .ok_or_else(|| {
838            TokenRejection::Invalid("malformed token header: not a base64url JSON object".into())
839        })?;
840    if header.contains_key("crit") {
841        return Err(TokenRejection::Invalid(
842            "token header lists critical extensions (crit), none of which this server supports"
843                .into(),
844        ));
845    }
846    Ok(())
847}
848
849/// Whether `nbf` is a NumericDate jsonwebtoken actually checks: a non-negative
850/// number it can read as whole seconds.
851fn nbf_is_numeric_date(nbf: &Value) -> bool {
852    nbf.as_u64().is_some()
853        || nbf
854            .as_f64()
855            .is_some_and(|f| f.is_finite() && f >= 0.0 && f < u64::MAX as f64)
856}
857
858/// The scope/`typ` posture a validator was built with; see the warning it
859/// drives in [`OAuthValidator::build`].
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861enum UnscopedPosture {
862    /// At least one required scope: an ID token (which carries no scope claim
863    /// on any mainstream server) is refused with 403.
864    Scoped,
865    /// No required scope, but `require_at_jwt` refuses anything not typed as
866    /// an access token.
867    UnscopedButTypEnforced,
868    /// No required scope and no `typ` enforcement: an ID token for the same
869    /// client (`aud` = client_id) is indistinguishable from an access token.
870    IdTokensAccepted,
871}
872
873fn unscoped_posture(config: &ResolvedOAuthConfig) -> UnscopedPosture {
874    match (config.required_scopes.is_empty(), config.require_at_jwt) {
875        (false, _) => UnscopedPosture::Scoped,
876        (true, true) => UnscopedPosture::UnscopedButTypEnforced,
877        (true, false) => UnscopedPosture::IdTokensAccepted,
878    }
879}
880
881/// Required scopes a client is never told to request: those missing from a
882/// non-empty `scopes_supported`. Empty when `scopes_supported` is empty, since
883/// the challenge then advertises the required scopes themselves.
884fn unadvertised_scopes(config: &ResolvedOAuthConfig) -> Vec<&str> {
885    if config.scopes_supported.is_empty() {
886        return Vec::new();
887    }
888    config
889        .required_scopes
890        .iter()
891        .filter(|s| !config.scopes_supported.contains(s))
892        .map(String::as_str)
893        .collect()
894}
895
896/// Whether `url` is plain `http://` to a host other than loopback/`localhost`.
897pub(crate) fn plain_http_non_loopback(url: &str) -> bool {
898    url.get(..7)
899        .is_some_and(|scheme| scheme.eq_ignore_ascii_case("http://"))
900        && !is_loopback_url(url)
901}
902
903/// Whether `url`'s host is a loopback address or `localhost`.
904pub(crate) fn is_loopback_url(url: &str) -> bool {
905    let Ok(parsed) = reqwest::Url::parse(url) else {
906        return false;
907    };
908    let Some(host) = parsed.host_str() else {
909        return false;
910    };
911    let host = host.trim_start_matches('[').trim_end_matches(']');
912    host == "localhost"
913        || host.ends_with(".localhost")
914        || host
915            .parse::<std::net::IpAddr>()
916            .is_ok_and(|ip| ip.is_loopback())
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use crate::config::KeyNamingBuf;
923    use crate::jwks::MAX_FETCH_BYTES;
924    use crate::testing::*;
925    use std::collections::HashMap;
926    use std::sync::atomic::Ordering;
927
928    fn oauth_config(jwks_uri: &str) -> ResolvedOAuthConfig {
929        resolved_config(jwks_uri)
930    }
931
932    /// Zero cooldown: a test that wants to observe a refetch should not have to
933    /// sleep out `JWKS_MIN_REFETCH_INTERVAL`.
934    fn validator_no_cooldown(jwks_uri: &str) -> OAuthValidator {
935        OAuthValidator::build(&oauth_config(jwks_uri), Duration::ZERO).unwrap()
936    }
937
938    fn validator(jwks_uri: &str) -> OAuthValidator {
939        OAuthValidator::new(&oauth_config(jwks_uri)).unwrap()
940    }
941
942    fn validator_with(cfg: ResolvedOAuthConfig) -> OAuthValidator {
943        OAuthValidator::new(&cfg).unwrap()
944    }
945
946    fn claims(extra: serde_json::Value) -> serde_json::Value {
947        let mut base = serde_json::json!({
948            "iss": ISSUER, "aud": AUDIENCE, "sub": "user-1", "exp": now() + 3600,
949        });
950        for (k, v) in extra.as_object().unwrap() {
951            base[k] = v.clone();
952        }
953        base
954    }
955
956    fn is_invalid<T: std::fmt::Debug>(r: &Result<T, TokenRejection>) -> bool {
957        matches!(r, Err(TokenRejection::Invalid(_)))
958    }
959
960    // ── construction ─────────────────────────────────────────────────────────
961
962    #[test]
963    fn construction_refuses_an_empty_audience_set_or_algorithm_list() {
964        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
965        cfg.audience = String::new();
966        let err = OAuthValidator::new(&cfg).unwrap_err();
967        assert!(matches!(err, ValidatorError::NoAudience { .. }));
968        assert_eq!(
969            err.to_string(),
970            "mcp.oauth: no accepted audience configured"
971        );
972
973        // OAuth fields at the root of the config: the block still gets a name.
974        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
975        cfg.audience = String::new();
976        cfg.key_naming = KeyNamingBuf::Dotted(String::new());
977        let err = OAuthValidator::new(&cfg).unwrap_err();
978        assert_eq!(
979            err.to_string(),
980            "OAuth config: no accepted audience configured"
981        );
982
983        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
984        cfg.algorithms.clear();
985        let err = OAuthValidator::new(&cfg).unwrap_err();
986        assert_eq!(err.to_string(), "mcp.oauth.algorithms is empty");
987
988        cfg.key_naming = KeyNamingBuf::Env("APP_OAUTH_".into());
989        let err = OAuthValidator::new(&cfg).unwrap_err();
990        assert_eq!(err.to_string(), "APP_OAUTH_ALGORITHMS is empty");
991    }
992
993    #[test]
994    fn construction_refuses_a_leeway_over_the_cap_set_after_resolving() {
995        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
996        cfg.leeway_secs = crate::MAX_LEEWAY_SECS;
997        OAuthValidator::new(&cfg).expect("the cap itself is allowed");
998
999        for leeway in [crate::MAX_LEEWAY_SECS + 1, 86_400, u64::MAX] {
1000            cfg.leeway_secs = leeway;
1001            let err = OAuthValidator::new(&cfg).unwrap_err();
1002            assert!(
1003                matches!(err, ValidatorError::LeewayTooLarge { .. }),
1004                "{err}"
1005            );
1006            assert_eq!(
1007                err.to_string(),
1008                format!("mcp.oauth.leeway_secs {leeway} is over the 300-second cap")
1009            );
1010        }
1011    }
1012
1013    #[test]
1014    fn the_unscoped_posture_is_classified_for_the_startup_log() {
1015        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1016        assert!(!cfg.required_scopes.is_empty());
1017        assert_eq!(unscoped_posture(&cfg), UnscopedPosture::Scoped);
1018        cfg.require_at_jwt = true;
1019        assert_eq!(unscoped_posture(&cfg), UnscopedPosture::Scoped);
1020
1021        // No scope: only `typ` enforcement keeps an ID token out.
1022        cfg.required_scopes.clear();
1023        assert_eq!(
1024            unscoped_posture(&cfg),
1025            UnscopedPosture::UnscopedButTypEnforced
1026        );
1027        cfg.require_at_jwt = false;
1028        assert_eq!(unscoped_posture(&cfg), UnscopedPosture::IdTokensAccepted);
1029    }
1030
1031    /// The combination the startup warning is about: with no required scope and
1032    /// `require_at_jwt` off, an ID token (typ `JWT`, no scope claim) signed for
1033    /// the same client is accepted; either setting turns it away.
1034    #[tokio::test]
1035    async fn an_id_token_is_accepted_only_when_unscoped_and_typ_is_not_enforced() {
1036        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1037        let id_token = mint_with(
1038            Algorithm::RS256,
1039            Some(KID_A),
1040            Some("JWT"),
1041            &claims(serde_json::json!({ "nonce": "n-1", "auth_time": now() })),
1042        );
1043
1044        let mut cfg = oauth_config(&jwks.url);
1045        cfg.required_scopes.clear();
1046        assert!(
1047            validator_with(cfg.clone())
1048                .validate(&id_token)
1049                .await
1050                .is_ok()
1051        );
1052
1053        let mut scoped = cfg.clone();
1054        scoped.required_scopes = vec!["mcp:read".into()];
1055        assert_eq!(
1056            validator_with(scoped).validate(&id_token).await,
1057            Err(TokenRejection::InsufficientScope)
1058        );
1059
1060        cfg.require_at_jwt = true;
1061        assert!(is_invalid(&validator_with(cfg).validate(&id_token).await));
1062    }
1063
1064    #[test]
1065    fn plain_http_detection_exempts_loopback_only() {
1066        assert!(plain_http_non_loopback("http://idp.example.com/jwks"));
1067        assert!(plain_http_non_loopback("HTTP://idp.example.com/jwks"));
1068        assert!(!plain_http_non_loopback("https://idp.example.com/jwks"));
1069        assert!(!plain_http_non_loopback("http://127.0.0.1:9000/jwks"));
1070        assert!(!plain_http_non_loopback("http://localhost/jwks"));
1071    }
1072
1073    #[test]
1074    fn accessors_expose_the_resource_and_where_its_metadata_lives() {
1075        let v = validator("http://127.0.0.1:1/jwks");
1076        assert_eq!(v.resource(), RESOURCE);
1077        assert_eq!(
1078            v.resource_metadata_url(),
1079            "https://kb.example.test/.well-known/oauth-protected-resource/mcp"
1080        );
1081        assert_eq!(
1082            v.metadata_path(),
1083            "/.well-known/oauth-protected-resource/mcp"
1084        );
1085        assert_eq!(v.config().issuer, ISSUER);
1086    }
1087
1088    // ── the metadata document and the challenge headers ──────────────────────
1089
1090    #[test]
1091    fn metadata_document_has_the_rfc_9728_shape() {
1092        let v = validator("http://127.0.0.1:1/jwks");
1093        let doc = v.metadata();
1094        assert_eq!(doc["resource"], RESOURCE);
1095        // Byte-identical, trailing slash and all — a client matches this against
1096        // the `iss` of the tokens it receives.
1097        assert_eq!(doc["authorization_servers"][0], ISSUER);
1098        assert_eq!(doc["scopes_supported"][0], "mcp:read");
1099        assert_eq!(doc["scopes_supported"][1], "mcp:write");
1100        assert_eq!(doc["bearer_methods_supported"][0], "header");
1101        // `resource_name` names the application, so the crate sets none of its
1102        // own; it is published exactly when the application supplies one.
1103        assert!(doc.get("resource_name").is_none());
1104
1105        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1106        cfg.resource_name = Some("mcp-md-wiki knowledge base (MCP)".into());
1107        let v = validator_with(cfg);
1108        let doc = v.metadata();
1109        assert_eq!(doc["resource_name"], "mcp-md-wiki knowledge base (MCP)");
1110        assert_eq!(
1111            doc.to_string(),
1112            "{\"authorization_servers\":[\"https://authentik.example.test/application/o/example-app/\"],\
1113             \"bearer_methods_supported\":[\"header\"],\
1114             \"resource\":\"https://kb.example.test/mcp\",\
1115             \"resource_name\":\"mcp-md-wiki knowledge base (MCP)\",\
1116             \"scopes_supported\":[\"mcp:read\",\"mcp:write\"]}"
1117        );
1118    }
1119
1120    #[test]
1121    fn invalid_token_challenge_is_well_formed() {
1122        let v = validator("http://127.0.0.1:1/jwks");
1123        assert_eq!(
1124            v.invalid_token_challenge(),
1125            "Bearer error=\"invalid_token\", \
1126             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
1127             scope=\"mcp:read mcp:write\""
1128        );
1129    }
1130
1131    #[test]
1132    fn invalid_token_challenge_names_the_required_scopes_when_none_is_advertised() {
1133        // An explicitly empty `scopes_supported`: the 401 still tells a client
1134        // what to ask for, rather than leaving it to request nothing and hit 403.
1135        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1136        cfg.scopes_supported = Vec::new();
1137        let v = validator_with(cfg.clone());
1138        assert_eq!(
1139            v.invalid_token_challenge(),
1140            "Bearer error=\"invalid_token\", \
1141             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
1142             scope=\"mcp:read\""
1143        );
1144        // RFC 9728 §3.2: a parameter with zero values is omitted.
1145        assert!(
1146            v.metadata().get("scopes_supported").is_none(),
1147            "{}",
1148            v.metadata()
1149        );
1150
1151        // RFC 6749 §3.3: `scope` holds at least one scope-token, so with nothing
1152        // advertised AND nothing required the parameter is left out.
1153        cfg.required_scopes.clear();
1154        assert_eq!(
1155            validator_with(cfg).invalid_token_challenge(),
1156            "Bearer error=\"invalid_token\", \
1157             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
1158        );
1159    }
1160
1161    #[test]
1162    fn insufficient_scope_challenge_names_the_missing_scope_not_the_menu() {
1163        // With a single required scope this is byte-identical to what
1164        // mcp-md-wiki sent before mcp-md-wiki#308.
1165        let v = validator("http://127.0.0.1:1/jwks");
1166        assert_eq!(
1167            v.insufficient_scope_challenge(),
1168            "Bearer error=\"insufficient_scope\", scope=\"mcp:read\", \
1169             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
1170        );
1171    }
1172
1173    #[test]
1174    fn insufficient_scope_challenge_lists_every_required_scope_space_delimited() {
1175        let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1176        cfg.required_scopes = vec!["mcp:read".into(), "mcp:write".into()];
1177        assert_eq!(
1178            validator_with(cfg).insufficient_scope_challenge(),
1179            "Bearer error=\"insufficient_scope\", scope=\"mcp:read mcp:write\", \
1180             resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
1181        );
1182    }
1183
1184    // ── token validation: the happy path and the original checks ─────────────
1185
1186    #[tokio::test]
1187    async fn a_well_formed_token_is_accepted_and_yields_its_scopes() {
1188        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1189        let v = validator(&jwks.url);
1190        let token = v.validate(&valid_token()).await.unwrap();
1191        assert_eq!(token.subject.as_deref(), Some("user-1"));
1192        assert_eq!(token.scopes, vec!["mcp:read", "mcp:write"]);
1193        assert!(token.has_scope("mcp:write"));
1194    }
1195
1196    #[tokio::test]
1197    async fn an_empty_credential_is_missing_not_invalid() {
1198        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1199        let v = validator(&jwks.url);
1200        assert_eq!(v.validate("").await.unwrap_err(), TokenRejection::Missing);
1201        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
1202    }
1203
1204    #[tokio::test]
1205    async fn a_wrong_issuer_is_rejected() {
1206        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1207        let v = validator(&jwks.url);
1208        // Same issuer minus the trailing slash: the near-miss that actually happens
1209        // in practice, not an obviously foreign string.
1210        let token = mint(
1211            KEY_A_PEM,
1212            KID_A,
1213            &claims(serde_json::json!({
1214                "iss": ISSUER.trim_end_matches('/'), "scope": "mcp:read",
1215            })),
1216        );
1217        assert!(is_invalid(&v.validate(&token).await));
1218    }
1219
1220    #[tokio::test]
1221    async fn an_issuer_array_containing_the_right_issuer_is_rejected() {
1222        // jsonwebtoken on its own accepts this; `iss` is a single StringOrURI.
1223        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1224        let v = validator(&jwks.url);
1225        let token = mint(
1226            KEY_A_PEM,
1227            KID_A,
1228            &claims(serde_json::json!({
1229                "iss": ["https://evil.example.test/", ISSUER], "scope": "mcp:read",
1230            })),
1231        );
1232        assert!(is_invalid(&v.validate(&token).await));
1233    }
1234
1235    #[tokio::test]
1236    async fn a_missing_issuer_or_audience_is_rejected() {
1237        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1238        let v = validator(&jwks.url);
1239        // jsonwebtoken only checks iss/aud when the claim is present, so omitting
1240        // them entirely is the way a token would sneak past a validator that had
1241        // not set `required_spec_claims`.
1242        for claims in [
1243            serde_json::json!({"aud": AUDIENCE, "exp": now() + 3600, "scope": "mcp:read"}),
1244            serde_json::json!({"iss": ISSUER, "exp": now() + 3600, "scope": "mcp:read"}),
1245        ] {
1246            let token = mint(KEY_A_PEM, KID_A, &claims);
1247            assert!(is_invalid(&v.validate(&token).await));
1248        }
1249    }
1250
1251    // ── audience ─────────────────────────────────────────────────────────────
1252
1253    #[tokio::test]
1254    async fn aud_is_accepted_as_a_string_and_as_an_array() {
1255        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1256        let v = validator(&jwks.url);
1257        for aud in [
1258            serde_json::json!(AUDIENCE),
1259            serde_json::json!(["some-other-client", AUDIENCE]),
1260        ] {
1261            let token = mint(
1262                KEY_A_PEM,
1263                KID_A,
1264                &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
1265            );
1266            assert!(
1267                v.validate(&token).await.is_ok(),
1268                "aud must be accepted in both RFC 7519 §4.1.3 shapes"
1269            );
1270        }
1271    }
1272
1273    #[tokio::test]
1274    async fn a_wrong_empty_or_malformed_audience_is_rejected() {
1275        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1276        let v = validator(&jwks.url);
1277        for aud in [
1278            serde_json::json!("some-other-client"),
1279            serde_json::json!([]),
1280            serde_json::json!(["some-other-client"]),
1281            serde_json::json!(42),
1282            serde_json::json!([AUDIENCE, 42]),
1283            serde_json::json!(""),
1284        ] {
1285            let token = mint(
1286                KEY_A_PEM,
1287                KID_A,
1288                &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
1289            );
1290            assert!(
1291                is_invalid(&v.validate(&token).await),
1292                "aud {aud} must never be accepted"
1293            );
1294        }
1295    }
1296
1297    #[tokio::test]
1298    async fn every_configured_audience_is_accepted_and_nothing_else() {
1299        // `audience` (single key) + `audiences` (list) are unioned: the migration
1300        // from client_id to resource-URL audience can run with both.
1301        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1302        let mut cfg = oauth_config(&jwks.url);
1303        cfg.audiences = vec![RESOURCE.to_string()];
1304        let v = validator_with(cfg);
1305        for aud in [AUDIENCE, RESOURCE] {
1306            let token = mint(
1307                KEY_A_PEM,
1308                KID_A,
1309                &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
1310            );
1311            assert!(v.validate(&token).await.is_ok(), "{aud} is configured");
1312        }
1313        let token = mint(
1314            KEY_A_PEM,
1315            KID_A,
1316            &claims(
1317                serde_json::json!({"aud": "https://other.example.test/mcp", "scope": "mcp:read"}),
1318            ),
1319        );
1320        assert!(is_invalid(&v.validate(&token).await));
1321    }
1322
1323    // ── expiry, not-before and clock skew ────────────────────────────────────
1324
1325    #[tokio::test]
1326    async fn an_expired_token_is_rejected_beyond_the_leeway() {
1327        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1328        let v = validator(&jwks.url);
1329        let token = mint(
1330            KEY_A_PEM,
1331            KID_A,
1332            &claims(serde_json::json!({
1333                "exp": now() - (crate::DEFAULT_LEEWAY_SECS + 60), "scope": "mcp:read",
1334            })),
1335        );
1336        assert!(is_invalid(&v.validate(&token).await));
1337    }
1338
1339    #[tokio::test]
1340    async fn skew_within_the_leeway_is_tolerated_and_zero_leeway_is_strict() {
1341        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1342        let just_expired = mint(
1343            KEY_A_PEM,
1344            KID_A,
1345            &claims(serde_json::json!({"exp": now() - 10, "scope": "mcp:read"})),
1346        );
1347        let not_yet_valid = mint(
1348            KEY_A_PEM,
1349            KID_A,
1350            &claims(serde_json::json!({"nbf": now() + 10, "scope": "mcp:read"})),
1351        );
1352
1353        let lenient = validator(&jwks.url);
1354        assert!(lenient.validate(&just_expired).await.is_ok());
1355        assert!(lenient.validate(&not_yet_valid).await.is_ok());
1356
1357        let mut cfg = oauth_config(&jwks.url);
1358        cfg.leeway_secs = 0;
1359        let strict = validator_with(cfg);
1360        assert!(is_invalid(&strict.validate(&just_expired).await));
1361        assert!(is_invalid(&strict.validate(&not_yet_valid).await));
1362    }
1363
1364    #[tokio::test]
1365    async fn a_token_used_before_nbf_is_rejected_beyond_the_leeway() {
1366        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1367        let v = validator(&jwks.url);
1368        let token = mint(
1369            KEY_A_PEM,
1370            KID_A,
1371            &claims(serde_json::json!({
1372                "nbf": now() + crate::DEFAULT_LEEWAY_SECS + 120, "scope": "mcp:read",
1373            })),
1374        );
1375        assert!(is_invalid(&v.validate(&token).await));
1376    }
1377
1378    #[tokio::test]
1379    async fn a_token_signed_by_the_wrong_key_is_rejected() {
1380        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1381        let v = validator(&jwks.url);
1382        // Signed by B but LABELLED as A, so the lookup succeeds and the failure is
1383        // genuinely a signature failure rather than an unknown-kid failure.
1384        let token = mint(
1385            KEY_B_PEM,
1386            KID_A,
1387            &claims(serde_json::json!({"scope": "mcp:read"})),
1388        );
1389        assert!(is_invalid(&v.validate(&token).await));
1390    }
1391
1392    // ── scope extraction: every shape ────────────────────────────────────────
1393
1394    async fn scopes_of(extra: serde_json::Value) -> Result<AuthorizedToken, TokenRejection> {
1395        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1396        let v = validator(&jwks.url);
1397        v.validate(&mint(KEY_A_PEM, KID_A, &claims(extra))).await
1398    }
1399
1400    #[tokio::test]
1401    async fn scope_as_a_space_delimited_string_is_read() {
1402        let t = scopes_of(serde_json::json!({"scope": "openid  mcp:read\tmcp:write"}))
1403            .await
1404            .unwrap();
1405        assert_eq!(t.scopes, ["openid", "mcp:read", "mcp:write"]);
1406    }
1407
1408    #[tokio::test]
1409    async fn scp_as_an_array_is_read() {
1410        // Authelia's shape — the incompatibility the `scp` fallback fixes.
1411        let t = scopes_of(serde_json::json!({"scp": ["mcp:read", "mcp:write"]}))
1412            .await
1413            .unwrap();
1414        assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
1415    }
1416
1417    #[tokio::test]
1418    async fn scp_as_a_space_delimited_string_is_read() {
1419        // Entra ID's (and Ory Hydra's `scope_claim: string`) shape.
1420        let t = scopes_of(serde_json::json!({"scp": "mcp:read mcp:write"}))
1421            .await
1422            .unwrap();
1423        assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
1424    }
1425
1426    #[tokio::test]
1427    async fn scope_and_scp_together_are_unioned_without_duplicates() {
1428        let t = scopes_of(serde_json::json!({
1429            "scope": "openid mcp:read", "scp": ["mcp:read", "mcp:write"],
1430        }))
1431        .await
1432        .unwrap();
1433        assert_eq!(t.scopes, ["openid", "mcp:read", "mcp:write"]);
1434    }
1435
1436    #[tokio::test]
1437    async fn the_required_scope_in_scp_alone_satisfies_the_check() {
1438        let t = scopes_of(serde_json::json!({"scope": "openid", "scp": ["mcp:read"]}))
1439            .await
1440            .unwrap();
1441        assert!(t.has_scope("mcp:read"));
1442    }
1443
1444    #[tokio::test]
1445    async fn neither_claim_or_non_string_shapes_are_insufficient_not_invalid() {
1446        for extra in [
1447            serde_json::json!({}),
1448            serde_json::json!({"scope": ""}),
1449            serde_json::json!({"scope": "openid profile"}),
1450            serde_json::json!({"scp": []}),
1451            serde_json::json!({"scp": [1, {"mcp:read": true}]}),
1452            serde_json::json!({"scope": {"mcp:read": true}}),
1453            // Scope matching is exact and case-sensitive (RFC 6749 §3.3).
1454            serde_json::json!({"scope": "MCP:READ mcp:read:extra"}),
1455        ] {
1456            assert_eq!(
1457                scopes_of(extra.clone()).await.unwrap_err(),
1458                TokenRejection::InsufficientScope,
1459                "{extra} — the token itself is fine; conflating this with \
1460                 invalid_token sends the client round the authorization flow to the \
1461                 same refusal"
1462            );
1463        }
1464    }
1465
1466    #[tokio::test]
1467    async fn only_the_configured_scope_claims_are_read() {
1468        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1469        let mut cfg = oauth_config(&jwks.url);
1470        cfg.scope_claims = vec!["scope".to_string()];
1471        let v = validator_with(cfg);
1472        let token = mint(
1473            KEY_A_PEM,
1474            KID_A,
1475            &claims(serde_json::json!({"scp": ["mcp:read"]})),
1476        );
1477        assert_eq!(
1478            v.validate(&token).await.unwrap_err(),
1479            TokenRejection::InsufficientScope
1480        );
1481    }
1482
1483    // ── required scopes: all-of ──────────────────────────────────────────────
1484
1485    #[tokio::test]
1486    async fn every_required_scope_must_be_present() {
1487        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1488        let mut cfg = oauth_config(&jwks.url);
1489        cfg.required_scopes = vec!["mcp:read".into(), "mcp:write".into()];
1490        let v = validator_with(cfg);
1491        for (scope, ok) in [
1492            ("mcp:read", false),
1493            ("mcp:write", false),
1494            ("openid", false),
1495            ("mcp:read mcp:write", true),
1496            ("mcp:write openid mcp:read", true),
1497        ] {
1498            let token = mint(
1499                KEY_A_PEM,
1500                KID_A,
1501                &claims(serde_json::json!({ "scope": scope })),
1502            );
1503            let result = v.validate(&token).await;
1504            if ok {
1505                assert!(result.is_ok(), "{scope:?} carries every required scope");
1506            } else {
1507                assert_eq!(
1508                    result.unwrap_err(),
1509                    TokenRejection::InsufficientScope,
1510                    "{scope:?} lacks one"
1511                );
1512            }
1513        }
1514    }
1515
1516    #[tokio::test]
1517    async fn an_empty_required_scope_set_passes_the_scope_check() {
1518        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1519        let mut cfg = oauth_config(&jwks.url);
1520        cfg.required_scopes.clear();
1521        let v = validator_with(cfg);
1522        // No scope claim at all: still a valid token, just an unscoped one.
1523        let t = v
1524            .validate(&mint(KEY_A_PEM, KID_A, &claims(serde_json::json!({}))))
1525            .await
1526            .unwrap();
1527        assert!(t.scopes.is_empty());
1528        // Every other check still applies.
1529        let expired = mint(
1530            KEY_A_PEM,
1531            KID_A,
1532            &claims(serde_json::json!({"exp": now() - 3600})),
1533        );
1534        assert!(is_invalid(&v.validate(&expired).await));
1535    }
1536
1537    // ── principal ────────────────────────────────────────────────────────────
1538
1539    #[tokio::test]
1540    async fn the_principal_is_the_first_present_claim_of_the_chain() {
1541        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1542        let mut cfg = oauth_config(&jwks.url);
1543        cfg.principal_claims = vec!["preferred_username".into(), "email".into(), "sub".into()];
1544        let v = validator_with(cfg);
1545        for (extra, expected) in [
1546            (
1547                serde_json::json!({"preferred_username": "alice", "email": "a@example.com"}),
1548                "alice",
1549            ),
1550            (
1551                serde_json::json!({"preferred_username": "", "email": "a@example.com"}),
1552                "a@example.com",
1553            ),
1554            (serde_json::json!({"preferred_username": 7}), "user-1"),
1555        ] {
1556            let mut c = claims(extra);
1557            c["scope"] = "mcp:read".into();
1558            let t = v.validate(&mint(KEY_A_PEM, KID_A, &c)).await.unwrap();
1559            assert_eq!(t.principal.as_deref(), Some(expected));
1560        }
1561    }
1562
1563    /// `subject` and `principal` are identity values a handler may key on, so
1564    /// two signed values sharing a long prefix must stay distinct: truncation
1565    /// happens at log call sites only.
1566    #[tokio::test]
1567    async fn long_subjects_and_principals_are_kept_verbatim() {
1568        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1569        let mut cfg = oauth_config(&jwks.url);
1570        cfg.principal_claims = vec!["email".into()];
1571        let v = validator_with(cfg);
1572        let prefix = "u".repeat(200);
1573        let mut seen = Vec::new();
1574        for suffix in ["-a", "-b"] {
1575            let sub = format!("{prefix}{suffix}");
1576            let email = format!("{prefix}{suffix}@example.com");
1577            let c = claims(serde_json::json!({"sub": sub, "email": email, "scope": "mcp:read"}));
1578            let t = v.validate(&mint(KEY_A_PEM, KID_A, &c)).await.unwrap();
1579            assert_eq!(t.subject.as_deref(), Some(sub.as_str()));
1580            assert_eq!(t.principal.as_deref(), Some(email.as_str()));
1581            seen.push(t.subject);
1582        }
1583        assert_ne!(seen[0], seen[1]);
1584    }
1585
1586    // ── algorithm and key confusion ──────────────────────────────────────────
1587
1588    #[tokio::test]
1589    async fn alg_none_is_rejected_before_any_jwks_fetch() {
1590        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1591        let v = validator(&jwks.url);
1592        // Hand-assembled (no crate will sign `none`): base64url of
1593        // `{"alg":"none","typ":"JWT"}` / `{"alg":"None"}`, a payload with a
1594        // plausible claim set, and an empty signature.
1595        let payload = "eyJpc3MiOiJ4IiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJzY29wZSI6Im1jcDpyZWFkIiwiZXhwIjo5OTk5OTk5OTk5fQ";
1596        for header in ["eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0", "eyJhbGciOiJOb25lIn0"] {
1597            let token = format!("{header}.{payload}.");
1598            assert!(is_invalid(&v.validate(&token).await), "{header}");
1599        }
1600        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
1601    }
1602
1603    #[tokio::test]
1604    async fn hs256_signed_with_the_public_key_is_rejected_before_any_jwks_fetch() {
1605        // The classic confusion: an attacker HMACs a token with the server's
1606        // PUBLIC key bytes and hopes the verifier treats them as the HMAC secret.
1607        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1608        let v = validator(&jwks.url);
1609        let published = jwks_body();
1610        for secret in [N_A.as_bytes(), published.as_bytes()] {
1611            let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
1612            header.kid = Some(KID_A.to_string());
1613            let token = jsonwebtoken::encode(
1614                &header,
1615                &claims(serde_json::json!({"scope": "mcp:read"})),
1616                &jsonwebtoken::EncodingKey::from_secret(secret),
1617            )
1618            .unwrap();
1619            assert!(is_invalid(&v.validate(&token).await));
1620        }
1621        assert_eq!(
1622            jwks.hits.load(Ordering::SeqCst),
1623            0,
1624            "a junk algorithm must not be able to schedule IdP traffic"
1625        );
1626    }
1627
1628    #[tokio::test]
1629    async fn a_symmetric_key_in_the_jwks_is_never_used() {
1630        // Even a key set that (wrongly) publishes an `oct` key cannot make HMAC
1631        // verification reachable: the key is dropped at load, and HS* is not
1632        // configurable anyway.
1633        let body = jwks_of(&[serde_json::json!({"kty": "oct", "kid": KID_A, "k": "c2VjcmV0"})]);
1634        let jwks = spawn_jwks_server("200 OK", body).await;
1635        let v = validator(&jwks.url);
1636        assert!(is_invalid(&v.validate(&valid_token()).await));
1637    }
1638
1639    #[tokio::test]
1640    async fn a_token_alg_the_named_key_cannot_produce_is_rejected() {
1641        // Header says ES256 but names the RSA key: the key's type pins it to
1642        // RS*/PS*, so there is no key to verify with. Also the reverse.
1643        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
1644        let v = validator(&jwks.url);
1645        let c = claims(serde_json::json!({"scope": "mcp:read"}));
1646        let es_labelled_rsa = mint_with(Algorithm::ES256, Some(KID_A), None, &c.clone());
1647        assert!(is_invalid(&v.validate(&es_labelled_rsa).await));
1648        let rs_labelled_ec = mint_with(Algorithm::RS256, Some(KID_EC), None, &c.clone());
1649        assert!(is_invalid(&v.validate(&rs_labelled_ec).await));
1650        // KID_A declares `alg: RS256`, so it must refuse PS256 even though an RSA
1651        // key could technically verify it.
1652        let ps_on_rs_only_key = mint_with(Algorithm::PS256, Some(KID_A), None, &c);
1653        assert!(is_invalid(&v.validate(&ps_on_rs_only_key).await));
1654    }
1655
1656    #[tokio::test]
1657    async fn es256_ps256_and_eddsa_tokens_are_accepted() {
1658        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
1659        let v = validator(&jwks.url);
1660        let c = claims(serde_json::json!({"scope": "mcp:read"}));
1661        for (alg, kid) in [
1662            (Algorithm::ES256, KID_EC),
1663            (Algorithm::PS256, "test-key-a-pss"),
1664            (Algorithm::RS384, "test-key-a-pss"),
1665            (Algorithm::EdDSA, KID_ED),
1666            (Algorithm::RS256, KID_A),
1667        ] {
1668            let token = mint_with(alg, Some(kid), Some("at+jwt"), &c.clone());
1669            assert!(v.validate(&token).await.is_ok(), "{alg:?} must verify");
1670        }
1671    }
1672
1673    #[tokio::test]
1674    async fn an_algorithm_outside_the_allowlist_is_rejected_before_any_jwks_fetch() {
1675        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
1676        let mut cfg = oauth_config(&jwks.url);
1677        cfg.algorithms = vec![Algorithm::RS256];
1678        let v = validator_with(cfg);
1679        let token = mint_with(
1680            Algorithm::ES256,
1681            Some(KID_EC),
1682            None,
1683            &claims(serde_json::json!({"scope": "mcp:read"})),
1684        );
1685        assert!(is_invalid(&v.validate(&token).await));
1686        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
1687    }
1688
1689    #[tokio::test]
1690    async fn rejection_reasons_name_settings_per_key_naming() {
1691        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
1692        let token = mint_with(
1693            Algorithm::ES256,
1694            Some(KID_EC),
1695            None,
1696            &claims(serde_json::json!({"scope": "mcp:read"})),
1697        );
1698        for (naming, expected) in [
1699            (
1700                KeyNamingBuf::Dotted("mcp.oauth".into()),
1701                "token algorithm ES256 is not in mcp.oauth.algorithms",
1702            ),
1703            (
1704                KeyNamingBuf::Env("APP_OAUTH_".into()),
1705                "token algorithm ES256 is not in APP_OAUTH_ALGORITHMS",
1706            ),
1707        ] {
1708            let mut cfg = oauth_config(&jwks.url);
1709            cfg.algorithms = vec![Algorithm::RS256];
1710            cfg.key_naming = naming;
1711            assert_eq!(
1712                validator_with(cfg).validate(&token).await.unwrap_err(),
1713                TokenRejection::Invalid(expected.into())
1714            );
1715        }
1716    }
1717
1718    // ── typ ──────────────────────────────────────────────────────────────────
1719
1720    #[tokio::test]
1721    async fn typ_access_token_types_pass_and_other_jwt_types_fail() {
1722        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1723        let v = validator(&jwks.url);
1724        let c = claims(serde_json::json!({"scope": "mcp:read"}));
1725        for typ in [
1726            None,
1727            Some("JWT"),
1728            Some("jwt"),
1729            Some("at+jwt"),
1730            Some("AT+JWT"),
1731            Some("application/at+jwt"),
1732        ] {
1733            let token = mint_with(Algorithm::RS256, Some(KID_A), typ, &c.clone());
1734            assert!(v.validate(&token).await.is_ok(), "typ {typ:?} must pass");
1735        }
1736        for typ in ["dpop+jwt", "logout+jwt", "secevent+jwt", "JOSE"] {
1737            let token = mint_with(Algorithm::RS256, Some(KID_A), Some(typ), &c.clone());
1738            assert!(is_invalid(&v.validate(&token).await), "typ {typ} must fail");
1739        }
1740    }
1741
1742    #[tokio::test]
1743    async fn require_at_jwt_refuses_plain_jwt_and_a_missing_typ() {
1744        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1745        let mut cfg = oauth_config(&jwks.url);
1746        cfg.require_at_jwt = true;
1747        let v = validator_with(cfg);
1748        let c = claims(serde_json::json!({"scope": "mcp:read"}));
1749        for typ in [None, Some("JWT")] {
1750            let token = mint_with(Algorithm::RS256, Some(KID_A), typ, &c.clone());
1751            assert!(is_invalid(&v.validate(&token).await), "typ {typ:?}");
1752        }
1753        let token = mint_with(Algorithm::RS256, Some(KID_A), Some("at+jwt"), &c);
1754        assert!(v.validate(&token).await.is_ok());
1755    }
1756
1757    // ── credential shape ─────────────────────────────────────────────────────
1758
1759    #[tokio::test]
1760    async fn garbage_opaque_and_oversized_credentials_are_rejected_without_a_fetch() {
1761        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1762        let v = validator(&jwks.url);
1763        let oversized = format!("{}.{}.{}", "a".repeat(MAX_TOKEN_BYTES), "b", "c");
1764        for junk in [
1765            "not-a-jwt",
1766            "a.b.c",
1767            "a.b",
1768            // An Authelia-style opaque access token.
1769            "authelia_at_Xy9vQ3c2bG9uZ3JhbmRvbXN0cmluZw.abc",
1770            oversized.as_str(),
1771        ] {
1772            assert!(is_invalid(&v.validate(junk).await), "{junk:.40}");
1773        }
1774        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
1775    }
1776
1777    /// Unpadded base64url, for hand-built token headers.
1778    fn b64url(bytes: &[u8]) -> String {
1779        const ALPHABET: &[u8; 64] =
1780            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1781        let mut out = String::new();
1782        for chunk in bytes.chunks(3) {
1783            let n = chunk
1784                .iter()
1785                .enumerate()
1786                .fold(0u32, |acc, (i, &b)| acc | (u32::from(b) << (16 - 8 * i)));
1787            for i in 0..=chunk.len() {
1788                out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
1789            }
1790        }
1791        out
1792    }
1793
1794    /// An unknown `alg` string is echoed verbatim by the header parser's error;
1795    /// the rejection reason (which reaches a warn-level log line) must not carry
1796    /// all of it.
1797    #[tokio::test]
1798    async fn a_malformed_header_reason_is_truncated() {
1799        let v = validator("http://127.0.0.1:1/jwks");
1800        let header = format!(r#"{{"alg":"{}","typ":"JWT"}}"#, "A".repeat(8 * 1024));
1801        let token = format!("{}.e30.sig", b64url(header.as_bytes()));
1802        match v.validate(&token).await {
1803            Err(TokenRejection::Invalid(reason)) => {
1804                assert!(
1805                    reason.starts_with("malformed token header: "),
1806                    "{reason:.80}"
1807                );
1808                assert!(
1809                    reason.chars().count() <= "malformed token header: ".len() + 129,
1810                    "{} chars",
1811                    reason.chars().count()
1812                );
1813            }
1814            other => panic!("expected Invalid, got {other:?}"),
1815        }
1816    }
1817
1818    /// A token signed with [`KEY_A_PEM`] whose protected header is exactly
1819    /// `header` — for header members jsonwebtoken's `Header` cannot express.
1820    fn mint_raw_header(header: serde_json::Value, claims: serde_json::Value) -> String {
1821        let input = format!(
1822            "{}.{}",
1823            b64url(header.to_string().as_bytes()),
1824            b64url(claims.to_string().as_bytes())
1825        );
1826        let key = jsonwebtoken::EncodingKey::from_rsa_pem(KEY_A_PEM.as_bytes()).unwrap();
1827        let signature =
1828            jsonwebtoken::crypto::sign(input.as_bytes(), &key, jsonwebtoken::Algorithm::RS256)
1829                .unwrap();
1830        format!("{input}.{signature}")
1831    }
1832
1833    /// RFC 7515 §4.1.11: a `crit` header naming an extension the recipient does
1834    /// not understand makes the JWS invalid. This crate understands none, so
1835    /// every `crit` — unknown, empty or malformed — is refused, before any key
1836    /// is fetched.
1837    #[tokio::test]
1838    async fn a_crit_header_is_refused_before_any_jwks_fetch() {
1839        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1840        let v = validator(&jwks.url);
1841        let c = claims(serde_json::json!({"scope": "mcp:read"}));
1842        for crit in [
1843            serde_json::json!(["urn:example:must-understand"]),
1844            serde_json::json!([]),
1845            serde_json::json!("not-an-array"),
1846        ] {
1847            let token = mint_raw_header(
1848                serde_json::json!({
1849                    "alg": "RS256", "kid": KID_A, "crit": crit,
1850                    "urn:example:must-understand": true,
1851                }),
1852                c.clone(),
1853            );
1854            assert_eq!(
1855                v.validate(&token).await,
1856                Err(TokenRejection::Invalid(
1857                    "token header lists critical extensions (crit), none of which this \
1858                     server supports"
1859                        .into()
1860                )),
1861                "crit {crit}"
1862            );
1863        }
1864        assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
1865        // The same hand-built header without `crit` is accepted, so it is the
1866        // `crit` being refused, not the construction.
1867        let token = mint_raw_header(serde_json::json!({"alg": "RS256", "kid": KID_A}), c);
1868        assert!(v.validate(&token).await.is_ok());
1869    }
1870
1871    /// RFC 7519 §4.1.5: `nbf` is a NumericDate. jsonwebtoken silently skips one
1872    /// it cannot read as a number, which would make a not-yet-valid token valid.
1873    #[tokio::test]
1874    async fn an_nbf_that_is_not_a_numeric_date_is_refused() {
1875        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1876        let v = validator(&jwks.url);
1877        let later = now() + 365 * 24 * 3600;
1878        for nbf in [
1879            serde_json::json!(later.to_string()),
1880            serde_json::json!("later"),
1881            serde_json::json!(1e30),
1882            serde_json::json!(-5),
1883            serde_json::json!(null),
1884        ] {
1885            let token = mint(
1886                KEY_A_PEM,
1887                KID_A,
1888                &claims(serde_json::json!({"nbf": nbf, "scope": "mcp:read"})),
1889            );
1890            assert_eq!(
1891                v.validate(&token).await,
1892                Err(TokenRejection::Invalid(
1893                    "token nbf is not a NumericDate (a non-negative number of seconds)".into()
1894                )),
1895                "nbf {nbf}"
1896            );
1897        }
1898        // An array fails jsonwebtoken's own claim parsing: refused either way.
1899        let token = mint(
1900            KEY_A_PEM,
1901            KID_A,
1902            &claims(serde_json::json!({"nbf": [later], "scope": "mcp:read"})),
1903        );
1904        assert!(is_invalid(&v.validate(&token).await));
1905        // Numbers, integral or not, are checked normally.
1906        let past = mint(
1907            KEY_A_PEM,
1908            KID_A,
1909            &claims(serde_json::json!({"nbf": now() as f64 - 10.5, "scope": "mcp:read"})),
1910        );
1911        assert!(v.validate(&past).await.is_ok());
1912        let future = mint(
1913            KEY_A_PEM,
1914            KID_A,
1915            &claims(serde_json::json!({"nbf": later, "scope": "mcp:read"})),
1916        );
1917        match v.validate(&future).await {
1918            Err(TokenRejection::Invalid(reason)) => {
1919                assert!(reason.contains("ImmatureSignature"), "{reason}");
1920            }
1921            other => panic!("expected Invalid, got {other:?}"),
1922        }
1923    }
1924
1925    /// RFC 9449 §7.2 / RFC 8705 §3: a sender-constrained token must not be
1926    /// accepted as a bearer token by a server that cannot check the binding.
1927    #[tokio::test]
1928    async fn a_sender_constrained_token_is_refused() {
1929        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1930        let v = validator(&jwks.url);
1931        for cnf in [
1932            serde_json::json!({"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"}),
1933            serde_json::json!({"x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"}),
1934            serde_json::json!(null),
1935        ] {
1936            let token = mint_with(
1937                crate::Algorithm::RS256,
1938                Some(KID_A),
1939                Some("at+jwt"),
1940                &claims(serde_json::json!({"cnf": cnf, "scope": "mcp:read"})),
1941            );
1942            assert_eq!(
1943                v.validate(&token).await,
1944                Err(TokenRejection::Invalid(
1945                    "token is sender-constrained (cnf); this server accepts bearer tokens only"
1946                        .into()
1947                )),
1948                "cnf {cnf}"
1949            );
1950        }
1951    }
1952
1953    // ── JWKS fetching, rotation and rate limiting ────────────────────────────
1954
1955    #[tokio::test]
1956    async fn the_jwks_is_fetched_once_and_cached() {
1957        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1958        let v = validator(&jwks.url);
1959        for _ in 0..3 {
1960            v.validate(&valid_token()).await.unwrap();
1961        }
1962        assert_eq!(
1963            jwks.hits.load(Ordering::SeqCst),
1964            1,
1965            "a cached key must not be re-fetched per request"
1966        );
1967    }
1968
1969    #[tokio::test]
1970    async fn an_unknown_kid_does_not_refetch_during_the_cooldown() {
1971        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1972        let v = validator(&jwks.url); // real 60s cooldown
1973        // First call populates the cache (one fetch); the unknown kid is then NOT
1974        // worth a second fetch, because we just fetched.
1975        let token = mint(
1976            KEY_A_PEM,
1977            "rotated-key",
1978            &claims(serde_json::json!({"scope": "mcp:read"})),
1979        );
1980        for _ in 0..5 {
1981            assert!(is_invalid(&v.validate(&token).await));
1982        }
1983        assert_eq!(
1984            jwks.hits.load(Ordering::SeqCst),
1985            1,
1986            "kid is attacker-controlled — five junk tokens must not mean five IdP hits"
1987        );
1988    }
1989
1990    #[tokio::test]
1991    async fn concurrent_unknown_kids_cost_one_fetch() {
1992        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1993        let v = Arc::new(validator(&jwks.url));
1994        let mut tasks = Vec::new();
1995        for i in 0..20 {
1996            let v = Arc::clone(&v);
1997            tasks.push(tokio::spawn(async move {
1998                let token = mint(
1999                    KEY_A_PEM,
2000                    &format!("junk-{i}"),
2001                    &claims(serde_json::json!({"scope": "mcp:read"})),
2002                );
2003                v.validate(&token).await
2004            }));
2005        }
2006        for t in tasks {
2007            assert!(is_invalid(&t.await.unwrap()));
2008        }
2009        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
2010    }
2011
2012    #[tokio::test]
2013    async fn an_unknown_kid_refetches_once_the_cooldown_has_passed() {
2014        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2015        let v = validator_no_cooldown(&jwks.url);
2016        let token = mint(
2017            KEY_A_PEM,
2018            "rotated-key",
2019            &claims(serde_json::json!({"scope": "mcp:read"})),
2020        );
2021        assert!(is_invalid(&v.validate(&token).await));
2022        assert!(is_invalid(&v.validate(&token).await));
2023        assert_eq!(
2024            jwks.hits.load(Ordering::SeqCst),
2025            2,
2026            "with the cooldown elapsed, an unknown kid must trigger a refresh — this \
2027             is how a rotated signing key is picked up without a restart"
2028        );
2029    }
2030
2031    #[tokio::test]
2032    async fn a_rotated_key_is_picked_up_and_a_withdrawn_key_is_dropped() {
2033        let jwks = spawn_http_server(HashMap::new(), None).await;
2034        let set = |body: String| {
2035            jwks.routes
2036                .lock()
2037                .unwrap()
2038                .insert("/jwks".to_string(), ("200 OK", body));
2039        };
2040        set(jwks_body());
2041        let v = validator_no_cooldown(&jwks.url);
2042        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2043        let old = mint(KEY_A_PEM, KID_A, &c.clone());
2044        let new = mint_with(Algorithm::ES256, Some(KID_EC), None, &c);
2045
2046        assert!(v.validate(&old).await.is_ok());
2047        // The AS publishes the new key alongside the old one: the unknown kid
2048        // triggers a refetch and both verify.
2049        set(jwks_of(&[jwk_rsa_a(), jwk_ec()]));
2050        assert!(v.validate(&new).await.is_ok());
2051        assert!(v.validate(&old).await.is_ok());
2052        // The AS withdraws the old key; the next refresh (the background task's
2053        // job) must stop trusting it.
2054        set(jwks_of(&[jwk_ec()]));
2055        assert_eq!(v.refresh_now().await.unwrap(), 1);
2056        assert!(is_invalid(&v.validate(&old).await));
2057        assert!(v.validate(&new).await.is_ok());
2058    }
2059
2060    #[tokio::test]
2061    async fn a_slow_refresh_does_not_stall_requests_whose_key_is_cached() {
2062        // Holding the key lock across the fetch would, with tokio's
2063        // writer-preferring RwLock, park every request behind a slow IdP.
2064        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2065        let v = Arc::new(validator_no_cooldown(&jwks.url));
2066        v.validate(&valid_token()).await.unwrap();
2067
2068        jwks.delay_ms.store(1500, Ordering::SeqCst);
2069        let background = Arc::clone(&v);
2070        let refresh = tokio::spawn(async move { background.refresh_now().await });
2071        // And an unknown-kid request that also wants a refresh, queued behind it.
2072        let junk = Arc::clone(&v);
2073        let queued = tokio::spawn(async move {
2074            junk.validate(&mint(
2075                KEY_A_PEM,
2076                "unknown",
2077                &claims(serde_json::json!({"scope": "mcp:read"})),
2078            ))
2079            .await
2080        });
2081        tokio::time::sleep(Duration::from_millis(200)).await;
2082
2083        let fast = tokio::time::timeout(Duration::from_millis(500), v.validate(&valid_token()))
2084            .await
2085            .expect("a cached-key validation must not wait for the in-flight refresh");
2086        assert!(fast.is_ok());
2087        assert!(refresh.await.unwrap().is_ok());
2088        assert!(is_invalid(&queued.await.unwrap()));
2089    }
2090
2091    /// A caller that stops waiting mid-refetch (client disconnect, timeout
2092    /// layer) must not cancel the fetch: run inline, the drop would spend the
2093    /// unknown-`kid` cooldown with no keys loaded, and a legitimate token would
2094    /// then be refused for a minute.
2095    #[tokio::test]
2096    async fn a_dropped_validation_does_not_spend_the_refetch_cooldown() {
2097        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2098        jwks.delay_ms.store(500, Ordering::SeqCst);
2099        let v = validator(&jwks.url); // the real 60s cooldown
2100        assert!(
2101            tokio::time::timeout(Duration::from_millis(50), v.validate(&valid_token()))
2102                .await
2103                .is_err(),
2104            "the slow fetch outlives the caller"
2105        );
2106        jwks.delay_ms.store(0, Ordering::SeqCst);
2107        // The fetch the dropped call started finishes in its own task and loads
2108        // the key; this request waits for it rather than being refused.
2109        assert!(v.validate(&valid_token()).await.is_ok());
2110        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
2111    }
2112
2113    #[tokio::test]
2114    async fn the_background_task_stops_when_the_validator_is_dropped() {
2115        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2116        let v = Arc::new(validator(&jwks.url));
2117        let task = v.spawn_background_refresh();
2118        for _ in 0..200 {
2119            if jwks.hits.load(Ordering::SeqCst) > 0 {
2120                break;
2121            }
2122            tokio::time::sleep(Duration::from_millis(10)).await;
2123        }
2124        assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
2125        let weak = Arc::downgrade(&v);
2126        drop(v);
2127        tokio::time::timeout(Duration::from_secs(5), task)
2128            .await
2129            .expect("the task ends with the validator, not after its hour-long sleep")
2130            .unwrap();
2131        assert!(
2132            weak.upgrade().is_none(),
2133            "the task held no strong reference"
2134        );
2135    }
2136
2137    #[tokio::test]
2138    async fn a_failed_refresh_keeps_the_keys_already_held() {
2139        let jwks = spawn_http_server(HashMap::new(), None).await;
2140        jwks.routes
2141            .lock()
2142            .unwrap()
2143            .insert("/jwks".to_string(), ("200 OK", jwks_body()));
2144        let v = validator_no_cooldown(&jwks.url);
2145        assert!(v.validate(&valid_token()).await.is_ok());
2146        jwks.routes.lock().unwrap().insert(
2147            "/jwks".to_string(),
2148            ("503 Service Unavailable", "{}".into()),
2149        );
2150        assert!(v.refresh_now().await.is_err());
2151        assert!(
2152            v.validate(&valid_token()).await.is_ok(),
2153            "an IdP outage must not revoke keys that are still good"
2154        );
2155    }
2156
2157    #[tokio::test]
2158    async fn an_unreachable_jwks_endpoint_fails_closed() {
2159        // Port 1 refuses instantly.
2160        let v = validator("http://127.0.0.1:1/jwks");
2161        assert!(
2162            is_invalid(&v.validate(&valid_token()).await),
2163            "an IdP we cannot reach must mean 'no', never 'sure'"
2164        );
2165    }
2166
2167    #[tokio::test]
2168    async fn a_jwks_error_response_fails_closed() {
2169        let jwks = spawn_jwks_server("500 Internal Server Error", "{}".into()).await;
2170        let v = validator(&jwks.url);
2171        assert!(is_invalid(&v.validate(&valid_token()).await));
2172        // The refresh error carries the whole cause chain, outermost first.
2173        let err = v.refresh_now().await.unwrap_err().to_string();
2174        assert!(
2175            err.starts_with(&format!(
2176                "fetching the JWKS from {}: non-success status: ",
2177                jwks.url
2178            )),
2179            "{err}"
2180        );
2181        assert!(err.contains("500 Internal Server Error"), "{err}");
2182    }
2183
2184    #[tokio::test]
2185    async fn an_oversized_jwks_response_fails_closed() {
2186        let padding = "x".repeat(MAX_FETCH_BYTES);
2187        let body = format!("{{\"keys\":[{}],\"padding\":\"{padding}\"}}", jwk_rsa_a());
2188        let jwks = spawn_jwks_server("200 OK", body).await;
2189        let v = validator(&jwks.url);
2190        assert!(is_invalid(&v.validate(&valid_token()).await));
2191    }
2192
2193    #[tokio::test]
2194    async fn a_key_set_with_no_usable_keys_fails_closed() {
2195        let body = jwks_of(&[
2196            // Encryption key, symmetric key, a P-521 key ring cannot verify, and
2197            // an RSA key whose declared alg contradicts its type: none may verify.
2198            serde_json::json!({"kty": "RSA", "use": "enc", "kid": KID_A, "n": N_A, "e": "AQAB"}),
2199            serde_json::json!({"kty": "oct", "kid": "hmac", "k": "c2VjcmV0"}),
2200            serde_json::json!({"kty": "EC", "crv": "P-521", "kid": "p521", "x": "AA", "y": "AA"}),
2201            serde_json::json!({"kty": "RSA", "alg": "ES256", "kid": KID_A, "n": N_A, "e": "AQAB"}),
2202        ]);
2203        let jwks = spawn_jwks_server("200 OK", body).await;
2204        let v = validator(&jwks.url);
2205        assert!(is_invalid(&v.validate(&valid_token()).await));
2206        assert!(
2207            v.refresh_now()
2208                .await
2209                .unwrap_err()
2210                .to_string()
2211                .contains("fetching the JWKS")
2212        );
2213    }
2214
2215    #[tokio::test]
2216    async fn one_unparseable_key_does_not_take_the_usable_ones_down() {
2217        let body = jwks_of(&[
2218            serde_json::json!({"kty": "OKP", "crv": "X25519", "kid": "x", "x": "AA"}),
2219            serde_json::json!({"kty": "weird", "kid": "w"}),
2220            jwk_rsa_a(),
2221        ]);
2222        let jwks = spawn_jwks_server("200 OK", body).await;
2223        let v = validator(&jwks.url);
2224        assert!(v.validate(&valid_token()).await.is_ok());
2225    }
2226
2227    #[tokio::test]
2228    async fn a_kid_less_header_uses_the_single_compatible_key() {
2229        let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2230        let v = validator(&jwks.url);
2231        let c = claims(serde_json::json!({"scope": "mcp:read"}));
2232        let token = mint_with(Algorithm::RS256, None, None, &c.clone());
2233        assert!(v.validate(&token).await.is_ok());
2234
2235        // Two RSA keys that could both verify RS256: refuse rather than try each.
2236        let jwks = spawn_jwks_server(
2237            "200 OK",
2238            jwks_of(&[jwk_rsa_a(), jwk_rsa_a_any_alg("second")]),
2239        )
2240        .await;
2241        let v = validator(&jwks.url);
2242        assert!(is_invalid(&v.validate(&token).await));
2243    }
2244
2245    // ── discovery ────────────────────────────────────────────────────────────
2246
2247    /// Serve OIDC discovery for `issuer_path` on a fake server whose document
2248    /// claims `doc_issuer`, plus the JWKS.
2249    async fn discovery_server(
2250        issuer_path: &str,
2251        doc_issuer: impl Fn(&str) -> String,
2252        via_rfc8414: bool,
2253    ) -> (FakeJwksServer, String) {
2254        let server = spawn_http_server(HashMap::new(), None).await;
2255        let issuer = format!("{}{issuer_path}", server.base);
2256        let doc = serde_json::json!({
2257            "issuer": doc_issuer(&issuer),
2258            "jwks_uri": format!("{}/keys", server.base),
2259        })
2260        .to_string();
2261        let well_known = if via_rfc8414 {
2262            format!(
2263                "/.well-known/oauth-authorization-server{}",
2264                issuer_path.trim_end_matches('/')
2265            )
2266        } else {
2267            format!(
2268                "{}/.well-known/openid-configuration",
2269                issuer_path.trim_end_matches('/')
2270            )
2271        };
2272        {
2273            let mut routes = server.routes.lock().unwrap();
2274            routes.insert(well_known, ("200 OK", doc));
2275            routes.insert("/keys".to_string(), ("200 OK", jwks_body()));
2276        }
2277        (server, issuer)
2278    }
2279
2280    fn discovering_validator(issuer: &str) -> OAuthValidator {
2281        let mut cfg = oauth_config("");
2282        cfg.issuer = issuer.to_string();
2283        validator_with(cfg)
2284    }
2285
2286    fn token_from(issuer: &str) -> String {
2287        mint(
2288            KEY_A_PEM,
2289            KID_A,
2290            &claims(serde_json::json!({"iss": issuer, "scope": "mcp:read"})),
2291        )
2292    }
2293
2294    #[tokio::test]
2295    async fn an_omitted_jwks_uri_is_discovered_once_from_oidc_metadata() {
2296        // Per-application issuer with a trailing slash — Authentik's shape.
2297        let (server, issuer) =
2298            discovery_server("/application/o/wiki/", |i| i.to_string(), false).await;
2299        let v = discovering_validator(&issuer);
2300        for _ in 0..3 {
2301            assert!(v.validate(&token_from(&issuer)).await.is_ok());
2302        }
2303        assert_eq!(
2304            server.hits.load(Ordering::SeqCst),
2305            2,
2306            "one discovery fetch and one JWKS fetch, then cached"
2307        );
2308    }
2309
2310    #[tokio::test]
2311    async fn discovery_falls_back_to_rfc_8414_metadata() {
2312        let (_server, issuer) = discovery_server("/tenant", |i| i.to_string(), true).await;
2313        let v = discovering_validator(&issuer);
2314        assert!(v.validate(&token_from(&issuer)).await.is_ok());
2315    }
2316
2317    #[tokio::test]
2318    async fn a_discovery_document_for_a_different_issuer_is_refused() {
2319        // The near miss again: the document drops the trailing slash.
2320        let (server, issuer) = discovery_server(
2321            "/application/o/wiki/",
2322            |i| i.trim_end_matches('/').to_string(),
2323            false,
2324        )
2325        .await;
2326        let v = discovering_validator(&issuer);
2327        assert!(is_invalid(&v.validate(&token_from(&issuer)).await));
2328        let err = v.refresh_now().await.unwrap_err().to_string();
2329        assert!(err.contains("does not match mcp.oauth.issuer"), "{err}");
2330        assert!(
2331            err.starts_with("could not discover a jwks_uri for mcp.oauth.issuer "),
2332            "{err}"
2333        );
2334        assert!(err.contains("set mcp.oauth.jwks_uri explicitly"), "{err}");
2335        // Two candidate URLs per attempt, two attempts, and the mismatching
2336        // document's jwks_uri was never followed.
2337        assert_eq!(server.hits.load(Ordering::SeqCst), 4);
2338    }
2339
2340    #[test]
2341    fn loopback_detection() {
2342        assert!(is_loopback_url("http://127.0.0.1:8080/x"));
2343        assert!(is_loopback_url("http://[::1]:8080/x"));
2344        assert!(is_loopback_url("http://localhost/x"));
2345        assert!(!is_loopback_url("http://auth.example.com/x"));
2346        assert!(!is_loopback_url("not a url"));
2347    }
2348
2349    #[tokio::test]
2350    async fn a_loopback_issuer_cannot_discover_a_cleartext_non_loopback_jwks_uri() {
2351        // A loopback issuer needs no opt-in; the key URL its metadata names
2352        // is still held to `allow_insecure_http`.
2353        let server = spawn_http_server(HashMap::new(), None).await;
2354        let issuer = format!("{}/app/", server.base);
2355        let doc =
2356            serde_json::json!({"issuer": issuer, "jwks_uri": "http://idp.example.invalid/keys"})
2357                .to_string();
2358        server.routes.lock().unwrap().insert(
2359            "/app/.well-known/openid-configuration".to_string(),
2360            ("200 OK", doc),
2361        );
2362        let v = discovering_validator(&issuer);
2363        let err = v.refresh_now().await.unwrap_err().to_string();
2364        assert!(err.contains("plain http on a non-loopback host"), "{err}");
2365        assert!(err.contains("mcp.oauth.allow_insecure_http"), "{err}");
2366    }
2367
2368    #[tokio::test]
2369    async fn a_redirect_to_cleartext_on_a_non_loopback_host_is_refused() {
2370        // The status line carries a Location header: the fake server writes it
2371        // verbatim after `HTTP/1.1 `.
2372        let server = spawn_http_server(
2373            HashMap::from([(
2374                "/jwks".to_string(),
2375                (
2376                    "302 Found\r\nLocation: http://idp.example.invalid/keys",
2377                    String::new(),
2378                ),
2379            )]),
2380            None,
2381        )
2382        .await;
2383        let v = validator(&server.url);
2384        let err = v.refresh_now().await.unwrap_err().to_string();
2385        assert!(
2386            err.contains("redirect to plain http on a non-loopback host"),
2387            "{err}"
2388        );
2389        assert!(err.contains("mcp.oauth.allow_insecure_http"), "{err}");
2390        assert_eq!(server.hits.load(Ordering::SeqCst), 1);
2391    }
2392
2393    #[test]
2394    fn required_scopes_are_unadvertised_only_against_a_non_empty_menu() {
2395        let mut cfg = oauth_config("http://127.0.0.1/jwks");
2396        cfg.required_scopes = vec!["mcp:read".to_string()];
2397        cfg.scopes_supported = vec!["mcp:write".to_string()];
2398        assert_eq!(unadvertised_scopes(&cfg), ["mcp:read"]);
2399        cfg.scopes_supported = vec!["mcp:read".to_string(), "mcp:write".to_string()];
2400        assert!(unadvertised_scopes(&cfg).is_empty());
2401        // An empty menu makes the challenge name the required scopes, so nothing
2402        // is unadvertised.
2403        cfg.scopes_supported = Vec::new();
2404        assert!(unadvertised_scopes(&cfg).is_empty());
2405        let challenge = OAuthValidator::new(&cfg).unwrap().invalid_token_challenge();
2406        assert!(challenge.contains(r#"scope="mcp:read""#), "{challenge}");
2407    }
2408
2409    // ── regression: the production Authentik shape, unchanged ────────────────
2410
2411    /// The config block of the original production deployment (mcp-md-wiki),
2412    /// using ONLY the keys it had before provider-agnostic validation. Parsed from
2413    /// YAML when the `serde` feature is on, built literally otherwise.
2414    fn production_authentik_config(issuer: &str) -> crate::OAuthConfig {
2415        #[cfg(feature = "serde")]
2416        {
2417            let yaml = format!(
2418                "enabled: true\n\
2419                 issuer: \"{issuer}\"\n\
2420                 jwks_uri: \"{issuer}jwks/\"\n\
2421                 audience: \"example-client-id\"\n\
2422                 resource: \"https://kb.example.com/mcp\"\n\
2423                 required_scope: \"mcp:read\"\n\
2424                 scopes_supported: [\"mcp:read\", \"mcp:write\"]\n"
2425            );
2426            serde_yaml_ng::from_str(&yaml).unwrap()
2427        }
2428        #[cfg(not(feature = "serde"))]
2429        {
2430            crate::OAuthConfig {
2431                enabled: true,
2432                issuer: issuer.to_string(),
2433                jwks_uri: Some(format!("{issuer}jwks/")),
2434                audience: "example-client-id".into(),
2435                resource: "https://kb.example.com/mcp".into(),
2436                required_scope: Some("mcp:read".into()),
2437                scopes_supported: Some(vec!["mcp:read".into(), "mcp:write".into()]),
2438                ..crate::OAuthConfig::default()
2439            }
2440        }
2441    }
2442
2443    /// The exact config and token shape of the original production deployment
2444    /// (Authentik, per-application issuer with a trailing slash, JWKS at
2445    /// `<issuer>jwks/`, `aud` = the OAuth client_id as a string, `scope` a
2446    /// space-delimited string, RS256, header `typ: JWT`). It must validate with
2447    /// every newer key at its default. Hostnames are placeholders; the fake server
2448    /// stands in for the AS.
2449    #[tokio::test]
2450    async fn production_authentik_config_and_token_still_pass_unchanged() {
2451        let server = spawn_http_server(HashMap::new(), None).await;
2452        let issuer = format!("{}/application/o/example-app/", server.base);
2453        server.routes.lock().unwrap().insert(
2454            "/application/o/example-app/jwks/".to_string(),
2455            ("200 OK", jwks_body()),
2456        );
2457        let parsed = production_authentik_config(&issuer);
2458        let cfg = parsed
2459            .resolve(crate::KeyNaming::Dotted("mcp.oauth"))
2460            .unwrap()
2461            .expect("enabled");
2462        assert!(
2463            cfg.accept_static_bearer,
2464            "dual mode must stay on by default"
2465        );
2466        assert_eq!(cfg.required_scopes, ["mcp:read"]);
2467        let v = validator_with(cfg);
2468
2469        let token = mint_with(
2470            Algorithm::RS256,
2471            Some(KID_A),
2472            Some("JWT"),
2473            &serde_json::json!({
2474                "iss": issuer,
2475                "sub": "0000000000000000example",
2476                "aud": "example-client-id",
2477                "azp": "example-client-id",
2478                "exp": now() + 300,
2479                "iat": now(),
2480                "auth_time": now(),
2481                "acr": "goauthentik.io/providers/oauth2/default",
2482                "email": "user@example.com",
2483                "email_verified": true,
2484                "name": "Example User",
2485                "given_name": "Example User",
2486                "preferred_username": "example",
2487                "nickname": "example",
2488                "groups": ["wiki-users"],
2489                "scope": "openid email profile mcp:read mcp:write",
2490            }),
2491        );
2492        let t = v.validate(&token).await.unwrap();
2493        assert_eq!(t.principal.as_deref(), Some("example"));
2494        assert_eq!(
2495            t.scopes,
2496            ["openid", "email", "profile", "mcp:read", "mcp:write"]
2497        );
2498        // And the metadata and challenges are what they were before
2499        // provider-agnostic validation.
2500        assert_eq!(v.metadata()["authorization_servers"][0], issuer.as_str());
2501        assert!(
2502            v.invalid_token_challenge()
2503                .starts_with("Bearer error=\"invalid_token\", resource_metadata=")
2504        );
2505        assert_eq!(
2506            v.insufficient_scope_challenge(),
2507            "Bearer error=\"insufficient_scope\", scope=\"mcp:read\", \
2508             resource_metadata=\"https://kb.example.com/.well-known/oauth-protected-resource/mcp\""
2509        );
2510    }
2511
2512    // ── observed shapes: sandbox-tested authorization servers ────────────────
2513    //
2514    // These mirror token shapes captured from real Authelia 4.39.4 and Kanidm
2515    // sandboxes. Hostnames and ids are placeholders.
2516
2517    async fn accepts(
2518        cfg_edit: impl FnOnce(&mut ResolvedOAuthConfig),
2519        alg: Algorithm,
2520        kid: &str,
2521        typ: Option<&str>,
2522        token_claims: serde_json::Value,
2523    ) -> AuthorizedToken {
2524        let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2525        let mut cfg = oauth_config(&jwks.url);
2526        cfg_edit(&mut cfg);
2527        let v = validator_with(cfg);
2528        v.validate(&mint_with(alg, Some(kid), typ, &token_claims))
2529            .await
2530            .unwrap()
2531    }
2532
2533    #[tokio::test]
2534    async fn observed_shape_authelia_4_39_scp_array_and_resource_url_audience() {
2535        let issuer = "https://auth.example.com";
2536        let resource = "https://kb.example.com/mcp";
2537        let t = accepts(
2538            |c| {
2539                c.issuer = issuer.into();
2540                c.audience = resource.into();
2541                c.require_at_jwt = true;
2542            },
2543            Algorithm::RS256,
2544            "test-key-a-pss",
2545            Some("at+jwt"),
2546            serde_json::json!({
2547                "iss": issuer, "aud": [resource], "client_id": "example-client",
2548                "sub": "44726d41-0000-4000-8000-000000000000",
2549                "exp": now() + 3600, "iat": now(), "nbf": now(),
2550                "jti": "x", "scp": ["mcp:read", "mcp:write"],
2551            }),
2552        )
2553        .await;
2554        assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
2555        // No username claim in Authelia access tokens: the chain lands on `sub`.
2556        assert_eq!(
2557            t.principal.as_deref(),
2558            Some("44726d41-0000-4000-8000-000000000000")
2559        );
2560    }
2561
2562    #[tokio::test]
2563    async fn observed_shape_kanidm_es256_per_client_issuer_and_client_audience() {
2564        let issuer = "https://idm.example.com/oauth2/openid/example-client";
2565        let t = accepts(
2566            |c| {
2567                c.issuer = issuer.into();
2568                c.audience = "example-client".into();
2569                c.require_at_jwt = true;
2570            },
2571            Algorithm::ES256,
2572            KID_EC,
2573            Some("at+jwt"),
2574            serde_json::json!({
2575                "iss": issuer, "aud": "example-client", "client_id": "example-client",
2576                "sub": "00000000-0000-4000-8000-000000000001",
2577                "exp": now() + 900, "iat": now(), "nbf": now(), "jti": "x",
2578                "scope": "mcp:read openid profile",
2579            }),
2580        )
2581        .await;
2582        assert!(t.has_scope("mcp:read"));
2583    }
2584
2585    // ── documented-shape fixtures, NOT live-tested ───────────────────────────
2586    //
2587    // Each models the access-token shape the named authorization server
2588    // documents (or, where noted, its source code shows), to prove the generic
2589    // validator covers it with config alone. None of these has been run against
2590    // the real product; they are "documented-shape fixture, not live-tested" and
2591    // must not be cited as compatibility claims.
2592
2593    #[tokio::test]
2594    async fn documented_shape_fixture_not_live_tested_keycloak() {
2595        // Realm issuer, `typ` JWT (at+jwt is an opt-in client switch since 26.2),
2596        // `scope` string, `azp` = client, `preferred_username` present.
2597        let issuer = "https://sso.example.com/realms/home";
2598        let t = accepts(
2599            |c| {
2600                c.issuer = issuer.into();
2601                c.audience = "wiki".into();
2602            },
2603            Algorithm::RS256,
2604            KID_A,
2605            Some("JWT"),
2606            serde_json::json!({
2607                "iss": issuer, "aud": ["wiki", "account"], "azp": "wiki",
2608                "sub": "u", "exp": now() + 300, "typ": "Bearer",
2609                "preferred_username": "alice", "scope": "openid profile mcp:read",
2610            }),
2611        )
2612        .await;
2613        assert_eq!(t.principal.as_deref(), Some("alice"));
2614    }
2615
2616    #[tokio::test]
2617    async fn documented_shape_fixture_not_live_tested_okta_custom_as() {
2618        // Custom authorization server: no `typ` header at all, `scp` array,
2619        // `aud` = the configured API audience, `cid` = client.
2620        let issuer = "https://example.okta.com/oauth2/default";
2621        let t = accepts(
2622            |c| {
2623                c.issuer = issuer.into();
2624                c.audience = "api://default".into();
2625            },
2626            Algorithm::RS256,
2627            KID_A,
2628            None,
2629            serde_json::json!({
2630                "iss": issuer, "aud": "api://default", "cid": "client", "sub": "a@example.com",
2631                "exp": now() + 3600, "scp": ["openid", "mcp:read"],
2632            }),
2633        )
2634        .await;
2635        assert!(t.has_scope("mcp:read"));
2636    }
2637
2638    #[tokio::test]
2639    async fn documented_shape_fixture_not_live_tested_entra_id_v2() {
2640        // v2.0 tenant issuer, `typ` JWT, `scp` space-delimited string, `aud` = the
2641        // API's client id.
2642        let issuer = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0";
2643        let t = accepts(
2644            |c| {
2645                c.issuer = issuer.into();
2646                c.audience = "11111111-1111-1111-1111-111111111111".into();
2647            },
2648            Algorithm::RS256,
2649            KID_A,
2650            Some("JWT"),
2651            serde_json::json!({
2652                "iss": issuer, "aud": "11111111-1111-1111-1111-111111111111",
2653                "sub": "pairwise", "oid": "o", "exp": now() + 3600,
2654                "preferred_username": "alice@example.com", "scp": "mcp.read mcp:read",
2655            }),
2656        )
2657        .await;
2658        assert!(t.has_scope("mcp:read"));
2659    }
2660
2661    #[tokio::test]
2662    async fn documented_shape_fixture_not_live_tested_auth0() {
2663        // Issuer with a trailing slash, `aud` array (API identifier + userinfo),
2664        // `scope` string, both the Auth0 (`typ` JWT) and RFC 9068 (`at+jwt`)
2665        // profiles.
2666        let issuer = "https://tenant.example.auth0.com/";
2667        for typ in ["JWT", "at+jwt"] {
2668            let t = accepts(
2669                |c| {
2670                    c.issuer = issuer.into();
2671                    c.audience = "https://kb.example.com/mcp".into();
2672                },
2673                Algorithm::RS256,
2674                KID_A,
2675                Some(typ),
2676                serde_json::json!({
2677                    "iss": issuer,
2678                    "aud": ["https://kb.example.com/mcp", "https://tenant.example.auth0.com/userinfo"],
2679                    "azp": "client", "sub": "auth0|1", "exp": now() + 3600,
2680                    "scope": "openid mcp:read",
2681                }),
2682            )
2683            .await;
2684            assert!(t.has_scope("mcp:read"));
2685        }
2686    }
2687
2688    #[tokio::test]
2689    async fn documented_shape_fixture_not_live_tested_ory_hydra_jwt_strategy() {
2690        // Only with `strategies.access_token: jwt` (the default is opaque);
2691        // `scp` is a list by default, a string with `oauth2.jwt.scope_claim: string`.
2692        let issuer = "https://hydra.example.com/";
2693        for scp in [
2694            serde_json::json!(["mcp:read"]),
2695            serde_json::json!("offline mcp:read"),
2696        ] {
2697            let t = accepts(
2698                |c| {
2699                    c.issuer = issuer.into();
2700                    c.audience = "https://kb.example.com/mcp".into();
2701                },
2702                Algorithm::RS256,
2703                KID_A,
2704                Some("JWT"),
2705                serde_json::json!({
2706                    "iss": issuer, "aud": ["https://kb.example.com/mcp"], "sub": "u",
2707                    "client_id": "c", "exp": now() + 3600, "scp": scp, "ext": {},
2708                }),
2709            )
2710            .await;
2711            assert!(t.has_scope("mcp:read"));
2712        }
2713    }
2714
2715    #[tokio::test]
2716    async fn documented_shape_fixture_not_live_tested_logto_resource_indicator() {
2717        // `aud` = the registered API resource indicator (RFC 8707), `scope`
2718        // string, ES256 among its allowed signing algorithms.
2719        let issuer = "https://logto.example.com/oidc";
2720        let t = accepts(
2721            |c| {
2722                c.issuer = issuer.into();
2723                c.audience = "https://kb.example.com/mcp".into();
2724            },
2725            Algorithm::ES256,
2726            KID_EC,
2727            None,
2728            serde_json::json!({
2729                "iss": issuer, "aud": "https://kb.example.com/mcp", "sub": "u",
2730                "client_id": "c", "exp": now() + 3600, "scope": "mcp:read",
2731            }),
2732        )
2733        .await;
2734        assert!(t.has_scope("mcp:read"));
2735    }
2736
2737    #[tokio::test]
2738    async fn documented_shape_fixture_not_live_tested_casdoor_jwt_standard() {
2739        // Source-derived: no `typ` beyond jsonwebtoken's default, `aud` =
2740        // [client_id] (or [resource] when RFC 8707 is used), `scope` string,
2741        // `preferred_username` with the JWT-Standard token format.
2742        let issuer = "https://casdoor.example.com";
2743        let t = accepts(
2744            |c| {
2745                c.issuer = issuer.into();
2746                c.audience = "example-client-id".into();
2747            },
2748            Algorithm::RS256,
2749            KID_A,
2750            Some("JWT"),
2751            serde_json::json!({
2752                "iss": issuer, "aud": ["example-client-id"], "sub": "u",
2753                "exp": now() + 3600, "preferred_username": "alice",
2754                "scope": "openid mcp:read",
2755            }),
2756        )
2757        .await;
2758        assert_eq!(t.principal.as_deref(), Some("alice"));
2759    }
2760
2761    #[tokio::test]
2762    async fn documented_shape_fixture_not_live_tested_rauthy_eddsa_at_jwt() {
2763        // Source-derived: `typ` at+jwt, `scope` string, EdDSA available per
2764        // client, no `preferred_username` (the principal chain falls to `sub`).
2765        let issuer = "https://rauthy.example.com/auth/v1";
2766        let t = accepts(
2767            |c| {
2768                c.issuer = issuer.into();
2769                c.audience = "example-client".into();
2770                c.require_at_jwt = true;
2771            },
2772            Algorithm::EdDSA,
2773            KID_ED,
2774            Some("at+jwt"),
2775            serde_json::json!({
2776                "iss": issuer, "aud": "example-client", "azp": "example-client",
2777                "sub": "user-id", "exp": now() + 1800, "scope": "openid mcp:read",
2778            }),
2779        )
2780        .await;
2781        assert_eq!(t.principal.as_deref(), Some("user-id"));
2782    }
2783
2784    #[tokio::test]
2785    async fn documented_shape_fixture_not_live_tested_dex_needs_a_group_claim_as_scope() {
2786        // Source-derived: Dex's access token is an ID token (`aud` = client_id, no
2787        // `scope`/`scp` claim at all). The only generic way to gate it is to read
2788        // a group claim as the scope source — a compromise, see the design notes.
2789        let issuer = "https://dex.example.com";
2790        let t = accepts(
2791            |c| {
2792                c.issuer = issuer.into();
2793                c.audience = "example-client".into();
2794                c.scope_claims = vec!["groups".into()];
2795                c.required_scopes = vec!["wiki-users".into()];
2796            },
2797            Algorithm::RS256,
2798            KID_A,
2799            None,
2800            serde_json::json!({
2801                "iss": issuer, "aud": "example-client", "sub": "u",
2802                "exp": now() + 3600, "email": "a@example.com",
2803                "groups": ["wiki-users", "admins"],
2804            }),
2805        )
2806        .await;
2807        assert!(t.has_scope("wiki-users"));
2808    }
2809
2810    #[tokio::test]
2811    async fn documented_shape_fixture_not_live_tested_zitadel_jwt_mode() {
2812        // Only with the application's token type switched to JWT (opaque is the
2813        // alternative). `aud` holds the client ids and the project id.
2814        // Zitadel's scope claim shape is not documented where we looked; this
2815        // fixture exercises the aud-array/project-id part only.
2816        let issuer = "https://zitadel.example.com";
2817        let t = accepts(
2818            |c| {
2819                c.issuer = issuer.into();
2820                c.audience = "123456789012345678".into();
2821            },
2822            Algorithm::RS256,
2823            KID_A,
2824            None,
2825            serde_json::json!({
2826                "iss": issuer,
2827                "aud": ["234567890123456789@wiki", "123456789012345678"],
2828                "client_id": "234567890123456789@wiki", "sub": "u",
2829                "exp": now() + 3600, "scope": "openid mcp:read",
2830            }),
2831        )
2832        .await;
2833        assert!(t.has_scope("mcp:read"));
2834    }
2835}