Skip to main content

oauth_resource_server/
builder.rs

1//! [`OAuthValidatorBuilder`]: how an [`OAuthValidator`] fetches its keys,
2//! beyond what [`crate::OAuthConfig`] describes — extra TLS trust anchors, an
3//! explicit proxy, the fetch timeout, and a key set to start from.
4//!
5//! These are code, not config: an application wires them in where it builds
6//! the validator, and, like every config setting, they take effect only when
7//! a validator is built. No `reqwest` type appears here: the options are
8//! plain bytes, strings and durations, turned into the HTTP client's own
9//! types privately, so the HTTP library's version stays out of this crate's
10//! public API.
11
12use std::time::Duration;
13
14use tracing::{info, warn};
15
16use crate::config::ResolvedOAuthConfig;
17use crate::jwks::{
18    CachedKey, DEFAULT_FETCH_TIMEOUT, FetchSettings, JWKS_MIN_REFETCH_INTERVAL, MAX_FETCH_TIMEOUT,
19    MIN_FETCH_TIMEOUT, error_chain, keys_from_jwk_set_json, redact_url,
20};
21use crate::validator::{OAuthValidator, ValidatorError, parsed_plain_http_non_loopback};
22
23/// Builds an [`OAuthValidator`] with options for its metadata and JWKS
24/// fetches; get one from [`OAuthValidator::builder`].
25///
26/// With no option set, [`OAuthValidatorBuilder::build`] behaves exactly like
27/// [`OAuthValidator::new`] (which is implemented as that). Every option is
28/// checked by `build`, never earlier, and a refused one is a
29/// [`ValidatorError`] — nothing is silently ignored. Calling an option twice
30/// replaces the earlier value, except
31/// [`OAuthValidatorBuilder::add_root_certificate_pem`], which adds.
32///
33/// Every protection a fetched key set gets still applies whatever is set
34/// here: the redirect policy, the `https` and `allow_insecure_http` rules,
35/// the response size cap, the key-count cap and per-key algorithm narrowing.
36///
37/// `Debug` never prints the proxy URL (it may carry a credential) or the
38/// initial key set, only whether they are set.
39///
40/// # Examples
41///
42/// An authorization server behind a private CA, reached through a proxy:
43///
44/// ```no_run
45/// use std::sync::Arc;
46/// use std::time::Duration;
47///
48/// use oauth_resource_server::{KeyNaming, OAuthConfig, OAuthValidator};
49///
50/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
51/// let resolved = OAuthConfig {
52///     enabled: true,
53///     issuer: "https://idp.internal.example.com/".into(),
54///     audience: "example-api".into(),
55///     resource: "https://api.example.com/".into(),
56///     required_scope: Some("api:read".into()),
57///     ..OAuthConfig::default()
58/// }
59/// .resolve(KeyNaming::Dotted("oauth"))?
60/// .expect("OAuth is enabled");
61///
62/// let ca = std::fs::read("/etc/ssl/private-ca.pem")?;
63/// let validator = Arc::new(
64///     OAuthValidator::builder(&resolved)
65///         .add_root_certificate_pem(&ca)
66///         .proxy("http://proxy.example.com:3128")
67///         .fetch_timeout(Duration::from_secs(5))
68///         .build()?,
69/// );
70/// validator.spawn_background_refresh();
71/// # Ok(())
72/// # }
73/// ```
74#[must_use = "a builder does nothing until `build` is called"]
75#[derive(Clone)]
76pub struct OAuthValidatorBuilder {
77    config: ResolvedOAuthConfig,
78    root_pems: Vec<Vec<u8>>,
79    proxy: Option<String>,
80    fetch_timeout: Duration,
81    initial_jwks: Option<String>,
82    /// [`JWKS_MIN_REFETCH_INTERVAL`] except in this crate's own tests.
83    min_refetch_interval: Duration,
84}
85
86impl std::fmt::Debug for OAuthValidatorBuilder {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("OAuthValidatorBuilder")
89            .field("issuer", &redact_url(&self.config.issuer))
90            .field("root_certificate_pems", &self.root_pems.len())
91            .field("proxy", &self.proxy.as_ref().map(|_| "<redacted>"))
92            .field("fetch_timeout", &self.fetch_timeout)
93            .field(
94                "initial_jwks_bytes",
95                &self.initial_jwks.as_ref().map(String::len),
96            )
97            .finish_non_exhaustive()
98    }
99}
100
101impl OAuthValidatorBuilder {
102    pub(crate) fn new(config: &ResolvedOAuthConfig) -> Self {
103        Self {
104            config: config.clone(),
105            root_pems: Vec::new(),
106            proxy: None,
107            fetch_timeout: DEFAULT_FETCH_TIMEOUT,
108            initial_jwks: None,
109            min_refetch_interval: JWKS_MIN_REFETCH_INTERVAL,
110        }
111    }
112
113    /// Trust the certificate(s) in `pem` as TLS root certificates for the
114    /// metadata and JWKS fetches, **in addition to** the roots the TLS
115    /// feature already trusts (the Mozilla set for `rustls-tls`, the OS store
116    /// for `rustls-tls-native-roots` and `native-tls`) — never instead of
117    /// them. For an authorization server behind a private or internal CA.
118    /// When `native-tls` and a rustls feature are both enabled, reqwest uses
119    /// native-tls, so the OS store is what these are added to.
120    ///
121    /// `pem` may hold one certificate or a bundle of several (every
122    /// `CERTIFICATE` block is added). Call it again to add another file. It
123    /// works with every TLS feature.
124    ///
125    /// # Security
126    ///
127    /// A root added here can vouch for ANY host name, not only the
128    /// authorization server's, for this validator's fetches: whoever holds
129    /// the CA's private key can serve signing keys this validator trusts.
130    /// Add the CA that actually issues the authorization server's
131    /// certificate, and nothing broader.
132    ///
133    /// Pass only real CA certificates. Under rustls a trust anchor keeps
134    /// only its subject, public key and name constraints — its
135    /// `basicConstraints` and `keyUsage` are ignored — so a `CA:FALSE` leaf
136    /// certificate passed here becomes an anchor that can issue for any
137    /// host.
138    ///
139    /// # Errors
140    ///
141    /// None here; [`OAuthValidatorBuilder::build`] returns
142    /// [`ValidatorError::InvalidRootCertificate`] when `pem` holds no
143    /// certificate, one the TLS backend cannot parse or use, or any
144    /// private-key block (`-----BEGIN … PRIVATE KEY-----`): a resource server
145    /// never needs a private key, so one here is a mistake, not ignored.
146    ///
147    /// # Examples
148    ///
149    /// ```no_run
150    /// # fn f(resolved: &oauth_resource_server::ResolvedOAuthConfig)
151    /// #     -> Result<(), Box<dyn std::error::Error>> {
152    /// use oauth_resource_server::OAuthValidator;
153    ///
154    /// let validator = OAuthValidator::builder(resolved)
155    ///     .add_root_certificate_pem(&std::fs::read("/etc/ssl/private-ca.pem")?)
156    ///     .build()?;
157    /// # drop(validator);
158    /// # Ok(())
159    /// # }
160    /// ```
161    pub fn add_root_certificate_pem(mut self, pem: &[u8]) -> Self {
162        self.root_pems.push(pem.to_vec());
163        self
164    }
165
166    /// Send the metadata and JWKS fetches through the proxy at `url`
167    /// (`http://host:port`, or `https://` for a TLS connection to the proxy
168    /// itself; `user:password@` in it is sent as proxy Basic auth). https
169    /// fetches are tunnelled with `CONNECT`, so TLS still runs end to end to
170    /// the authorization server and its certificate is still checked.
171    ///
172    /// Without this, non-loopback fetches use reqwest's own proxy handling,
173    /// untouched: the `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY`
174    /// environment variables and, on macOS and Windows when reqwest's
175    /// `system-proxy` feature is on in the build, the system settings.
176    /// Setting a proxy here turns all of those off, `NO_PROXY` included.
177    /// Either way, a fetch of a loopback URL (`localhost`, `*.localhost`,
178    /// `127.0.0.0/8`, `::1`) uses a separate client with no proxy at all: a
179    /// loopback URL names this host, which through a proxy it would not, and
180    /// a plain-http loopback fetch would cross the network in cleartext. That
181    /// client resolves every name to `::1`/`127.0.0.1` itself, never through
182    /// DNS, and a fetch it starts may not be redirected off loopback, so
183    /// this proxy is never skipped for a non-loopback host. (A redirect from
184    /// a non-loopback URL to a loopback one stays in the client the fetch
185    /// started with; see the README's security model.)
186    ///
187    /// # Security
188    ///
189    /// The URL may carry a credential, so it never appears in a log line or
190    /// `Debug` other than redacted (`***@`), and a refused one never appears
191    /// in the error at all. A plain-`http` proxy on a non-loopback host needs
192    /// no opt-in: an https fetch through it is a `CONNECT` tunnel it cannot
193    /// read or alter, and a plain-http fetch already needed
194    /// `allow_insecure_http` for its own URL. A credential in such a proxy
195    /// URL, though, would cross the network in cleartext, so that is refused
196    /// unless `allow_insecure_http` is set (and then logged as a `warn`) —
197    /// use an `https://` proxy URL for a proxy that authenticates.
198    ///
199    /// # Errors
200    ///
201    /// None here; [`OAuthValidatorBuilder::build`] returns
202    /// [`ValidatorError::InvalidProxy`] unless `url` is an absolute `http` or
203    /// `https` URL with a host and nothing after the port (no path, query or
204    /// fragment), with no space, control or non-ASCII character, and — when
205    /// plain http on a non-loopback host — no credential unless
206    /// `allow_insecure_http` is set. SOCKS proxies are not supported.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// # let resolved = oauth_resource_server::OAuthConfig {
212    /// #     enabled: true,
213    /// #     issuer: "https://auth.example.com/".into(),
214    /// #     audience: "example-api".into(),
215    /// #     resource: "https://api.example.com/".into(),
216    /// #     required_scope: Some("api:read".into()),
217    /// #     ..Default::default()
218    /// # }
219    /// # .resolve(oauth_resource_server::KeyNaming::Dotted("oauth"))
220    /// # .unwrap()
221    /// # .unwrap();
222    /// use oauth_resource_server::OAuthValidator;
223    ///
224    /// let validator = OAuthValidator::builder(&resolved)
225    ///     .proxy("http://proxy.example.com:3128")
226    ///     .build()
227    ///     .unwrap();
228    /// # drop(validator);
229    /// ```
230    pub fn proxy(mut self, url: impl Into<String>) -> Self {
231        self.proxy = Some(url.into());
232        self
233    }
234
235    /// How long one metadata or JWKS request may take, connection through
236    /// the last byte of the body; [`DEFAULT_FETCH_TIMEOUT`] (10 s) unless
237    /// set.
238    ///
239    /// A refresh holds the refresh lock for as long as its requests take
240    /// (discovery can chain three), and a request whose key is not cached
241    /// waits behind it, so this is also how long a stalled authorization
242    /// server can hold up such a request.
243    ///
244    /// # Errors
245    ///
246    /// None here; [`OAuthValidatorBuilder::build`] returns
247    /// [`ValidatorError::FetchTimeoutOutOfRange`] outside
248    /// [`MIN_FETCH_TIMEOUT`]`..=`[`MAX_FETCH_TIMEOUT`] (1 s to 60 s), zero
249    /// included.
250    ///
251    /// # Examples
252    ///
253    /// ```
254    /// # let resolved = oauth_resource_server::OAuthConfig {
255    /// #     enabled: true,
256    /// #     issuer: "https://auth.example.com/".into(),
257    /// #     audience: "example-api".into(),
258    /// #     resource: "https://api.example.com/".into(),
259    /// #     required_scope: Some("api:read".into()),
260    /// #     ..Default::default()
261    /// # }
262    /// # .resolve(oauth_resource_server::KeyNaming::Dotted("oauth"))
263    /// # .unwrap()
264    /// # .unwrap();
265    /// use std::time::Duration;
266    ///
267    /// use oauth_resource_server::OAuthValidator;
268    ///
269    /// let validator = OAuthValidator::builder(&resolved)
270    ///     .fetch_timeout(Duration::from_secs(3))
271    ///     .build()
272    ///     .unwrap();
273    /// # drop(validator);
274    /// ```
275    pub fn fetch_timeout(mut self, timeout: Duration) -> Self {
276        self.fetch_timeout = timeout;
277        self
278    }
279
280    /// Start with the keys in `json`, a JWK Set (`{"keys": [...]}`), already
281    /// loaded: tokens they signed validate before — or without — any fetch.
282    /// For a warm start through an authorization-server outage, or a key set
283    /// shipped alongside the application. Read one from a file with
284    /// `std::fs::read_to_string` and pass the text.
285    ///
286    /// The set goes through exactly the checks a fetched one does: the
287    /// 256 KiB size cap, at most 64 keys considered, and per-key narrowing
288    /// (no HMAC `oct` key, no `use: enc` or `key_ops`-without-`verify` key,
289    /// each key limited to the configured algorithms its own type and `alg`
290    /// allow; an unusable entry is skipped).
291    ///
292    /// The seeded keys are refreshed like fetched ones: the first refresh
293    /// that succeeds (from [`OAuthValidator::spawn_background_refresh`],
294    /// [`OAuthValidator::refresh_now`] or an unknown `kid`) replaces them
295    /// with the authorization server's set, and a failed one keeps them.
296    /// Until then, [`OAuthValidator::key_set_status`] reports them in `keys`
297    /// (so [`OAuthValidator::is_ready`] is `true` from the start), with
298    /// `last_attempt` and `last_success` still `None`: no fetch has happened,
299    /// and `last_success` only ever means the authorization server answered.
300    /// `keys > 0` with `last_success == None` is how to tell seeded keys
301    /// apart. Seeded keys count as held, so a failed background pass retries
302    /// on the held-keys schedule (a minute, backing off to an hour), not the
303    /// 5-second keyless one.
304    ///
305    /// # Security
306    ///
307    /// Seeded keys are trusted exactly like keys the authorization server
308    /// served — until a refresh succeeds, even one it has since withdrawn.
309    /// Seed only a key set taken from the authorization server itself.
310    ///
311    /// # Errors
312    ///
313    /// None here; [`OAuthValidatorBuilder::build`] returns
314    /// [`ValidatorError::InvalidInitialJwks`] when `json` is over the size
315    /// cap, not JSON, not a JWK Set, or holds no key usable under the
316    /// configured algorithms.
317    ///
318    /// # Examples
319    ///
320    /// ```
321    /// # let resolved = oauth_resource_server::OAuthConfig {
322    /// #     enabled: true,
323    /// #     issuer: "https://auth.example.com/".into(),
324    /// #     audience: "example-api".into(),
325    /// #     resource: "https://api.example.com/".into(),
326    /// #     required_scope: Some("api:read".into()),
327    /// #     ..Default::default()
328    /// # }
329    /// # .resolve(oauth_resource_server::KeyNaming::Dotted("oauth"))
330    /// # .unwrap()
331    /// # .unwrap();
332    /// # let jwks = r#"{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","kid":"k",
333    /// #     "x":"wpIx0OTOkazNUz0OsLr6pbXMEWuAC7PyXfdjeT12isI",
334    /// #     "y":"f3g97NNNjq8W3-K-Nx9JIbnBadxSVz3aWyKk5dGqAtk"}]}"#;
335    /// use oauth_resource_server::OAuthValidator;
336    ///
337    /// // `jwks`: a JWK Set saved from the authorization server's jwks_uri.
338    /// let validator = OAuthValidator::builder(&resolved)
339    ///     .initial_jwks(jwks)
340    ///     .build()
341    ///     .unwrap();
342    /// assert!(validator.is_ready());
343    /// assert_eq!(validator.key_set_status().last_success, None);
344    /// ```
345    pub fn initial_jwks(mut self, json: &str) -> Self {
346        self.initial_jwks = Some(json.to_string());
347        self
348    }
349
350    /// Build the validator.
351    ///
352    /// Does no network I/O, like [`OAuthValidator::new`]: keys are fetched on
353    /// first use or by [`OAuthValidator::spawn_background_refresh`] (unless
354    /// seeded). Logs the same startup warnings `new` does.
355    ///
356    /// # Errors
357    ///
358    /// Every error [`OAuthValidator::new`] returns, checked first; then
359    /// [`ValidatorError::FetchTimeoutOutOfRange`],
360    /// [`ValidatorError::InvalidRootCertificate`],
361    /// [`ValidatorError::InvalidProxy`] or
362    /// [`ValidatorError::InvalidInitialJwks`] for a refused option (the
363    /// first one found, in that order).
364    pub fn build(self) -> Result<OAuthValidator, ValidatorError> {
365        OAuthValidator::from_builder(&self)
366    }
367
368    pub(crate) fn config(&self) -> &ResolvedOAuthConfig {
369        &self.config
370    }
371
372    pub(crate) fn refetch_interval(&self) -> Duration {
373        self.min_refetch_interval
374    }
375
376    #[cfg(test)]
377    pub(crate) fn min_refetch_interval(mut self, interval: Duration) -> Self {
378        self.min_refetch_interval = interval;
379        self
380    }
381
382    /// The HTTP client settings, every option checked.
383    pub(crate) fn fetch_settings(&self) -> Result<FetchSettings, ValidatorError> {
384        if !(MIN_FETCH_TIMEOUT..=MAX_FETCH_TIMEOUT).contains(&self.fetch_timeout) {
385            return Err(ValidatorError::FetchTimeoutOutOfRange {
386                timeout: self.fetch_timeout,
387                min: MIN_FETCH_TIMEOUT,
388                max: MAX_FETCH_TIMEOUT,
389            });
390        }
391        let mut roots = Vec::new();
392        for (index, pem) in self.root_pems.iter().enumerate() {
393            roots.extend(
394                parse_root_pem(pem)
395                    .map_err(|reason| ValidatorError::InvalidRootCertificate { index, reason })?,
396            );
397        }
398        let opt_in_key = self.config.key_naming.key("allow_insecure_http");
399        let proxy = self
400            .proxy
401            .as_deref()
402            .map(|raw| check_proxy(raw, self.config.allow_insecure_http, &opt_in_key))
403            .transpose()?;
404        Ok(FetchSettings {
405            timeout: self.fetch_timeout,
406            roots,
407            proxy,
408        })
409    }
410
411    /// The keys [`OAuthValidatorBuilder::initial_jwks`] seeds, through the
412    /// same parsing and narrowing as a fetched set; empty without one.
413    pub(crate) fn seed_keys(&self) -> Result<Vec<CachedKey>, ValidatorError> {
414        let Some(json) = &self.initial_jwks else {
415            return Ok(Vec::new());
416        };
417        let keys = keys_from_jwk_set_json(json, &self.config.algorithms, &self.config.key_naming)
418            .map_err(|e| ValidatorError::InvalidInitialJwks {
419            reason: e.to_string(),
420        })?;
421        info!(
422            keys = keys.len(),
423            "OAuth: signing keys seeded from the initial JWK Set; the first successful refresh \
424             replaces them"
425        );
426        Ok(keys)
427    }
428}
429
430/// Every certificate in `pem`, or why it is unusable. A PEM with no
431/// `CERTIFICATE` block is refused rather than silently adding nothing.
432///
433/// rustls parses a certificate's DER only when a client is built, and
434/// native-tls when the certificate is read, so a probe client trusting only
435/// these certificates is built here: a certificate either backend cannot use
436/// fails with its own index, not later as a generic HTTP-client error.
437fn parse_root_pem(pem: &[u8]) -> Result<Vec<reqwest::Certificate>, String> {
438    // A trust anchor is public; a resource server never needs a private key,
439    // and one handed over by mistake should not be quietly skipped.
440    if String::from_utf8_lossy(pem)
441        .lines()
442        .any(|line| line.trim_start().starts_with("-----BEGIN") && line.contains("PRIVATE KEY"))
443    {
444        return Err(
445            "it contains private-key material (a PRIVATE KEY block) — pass only the CA \
446             certificate; a resource server never needs a private key"
447                .to_string(),
448        );
449    }
450    let certs = reqwest::Certificate::from_pem_bundle(pem).map_err(|e| error_chain(&e))?;
451    if certs.is_empty() {
452        return Err("no PEM CERTIFICATE block found".to_string());
453    }
454    let mut probe = reqwest::Client::builder()
455        .tls_built_in_root_certs(false)
456        .no_proxy();
457    for cert in &certs {
458        probe = probe.add_root_certificate(cert.clone());
459    }
460    probe.build().map_err(|e| error_chain(&e))?;
461    Ok(certs)
462}
463
464/// The proxy URL, parsed, or a refusal that shows it only redacted.
465///
466/// A refused value is never echoed, not even redacted: a malformed URL can
467/// hide its credential where no parser sees userinfo
468/// (`http://alice:1234/s3cret@host` is host `alice`, port `1234`, path
469/// `/s3cret@host`). The error shows the scheme, when it is one of the
470/// proxy schemes, and nothing else.
471///
472/// A plain-http proxy URL on a non-loopback host carrying a credential is
473/// refused without `allow_insecure_http` (named by `opt_in_key`): the
474/// credential would cross the network in cleartext. Without a credential it
475/// is accepted — https fetches `CONNECT`-tunnel through it end to end.
476fn check_proxy(
477    raw: &str,
478    allow_insecure_http: bool,
479    opt_in_key: &str,
480) -> Result<reqwest::Url, ValidatorError> {
481    let shown = reqwest::Url::parse(raw)
482        .ok()
483        .filter(|u| {
484            matches!(
485                u.scheme(),
486                "http" | "https" | "socks4" | "socks4a" | "socks5" | "socks5h"
487            )
488        })
489        .map_or_else(
490            || "<redacted>".to_string(),
491            |u| format!("{}://<redacted>", u.scheme()),
492        );
493    let refuse = |reason: &str| ValidatorError::InvalidProxy {
494        proxy: shown.clone(),
495        reason: reason.to_string(),
496    };
497    if raw.is_empty() || raw.chars().any(|c| !c.is_ascii_graphic()) {
498        return Err(refuse(
499            "it is empty or contains a space, a control character or a non-ASCII character",
500        ));
501    }
502    // The parse error is not quoted: some carry the input's text.
503    let url = reqwest::Url::parse(raw).map_err(|_| refuse("it is not an absolute URL"))?;
504    if !matches!(url.scheme(), "http" | "https") {
505        return Err(refuse(
506            "it must be an http:// or https:// URL (SOCKS proxies are not supported)",
507        ));
508    }
509    if url.host_str().is_none_or(str::is_empty) {
510        return Err(refuse("it has no host"));
511    }
512    if url.query().is_some() || url.fragment().is_some() {
513        return Err(refuse("it must not carry a query or fragment"));
514    }
515    if url.path() != "/" {
516        return Err(refuse("it must not carry a path"));
517    }
518    // Decided on the parsed, normalized URL: `http:/alice:…@host`,
519    // `http:alice:…@host` and `HTTP:\\alice:…@host` all parse to
520    // `http://alice:…@host`, which a raw `http://` prefix test would miss.
521    let credential = !url.username().is_empty() || url.password().is_some();
522    if parsed_plain_http_non_loopback(&url) && credential {
523        if !allow_insecure_http {
524            return Err(refuse(&format!(
525                "it carries a credential but is plain http on a non-loopback host, so the \
526                 credential would cross the network in cleartext — use an https:// proxy URL, \
527                 or set {opt_in_key} to permit it"
528            )));
529        }
530        warn!(
531            proxy = %redact_url(url.as_str()),
532            "OAuth: the proxy URL carries a credential but is plain http on a non-loopback \
533             host — the credential crosses the network in cleartext ({opt_in_key} is set). \
534             Use an https:// proxy URL."
535        );
536    }
537    Ok(url)
538}
539
540#[cfg(test)]
541mod tests {
542    use std::collections::HashMap;
543    use std::sync::Arc;
544    use std::sync::atomic::{AtomicUsize, Ordering};
545
546    use serde_json::json;
547    use tokio::io::{AsyncReadExt, AsyncWriteExt};
548    use tokio::net::TcpListener;
549    use tokio_rustls::rustls;
550    use tokio_rustls::rustls::pki_types::pem::PemObject;
551    use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
552
553    use super::*;
554    use crate::RefreshErrorKind;
555    use crate::testing;
556
557    // A throwaway CA and a server certificate it signed (P-256, SAN
558    // `127.0.0.1` and `localhost`, valid until 2126), generated with openssl
559    // for this test suite only and used nowhere else. The private key is
560    // public knowledge: nothing outside these tests may trust this CA.
561    const TEST_CA_PEM: &str = "-----BEGIN CERTIFICATE-----
562MIIBtzCCAV2gAwIBAgIUZQgSlghpInufq/WjTUNuj0M1omMwCgYIKoZIzj0EAwIw
563KDEmMCQGA1UEAwwdb2F1dGgtcmVzb3VyY2Utc2VydmVyIHRlc3QgQ0EwIBcNMjYw
564OTI5MjAxMjAyWhgPMjEyNjA5MDUyMDEyMDJaMCgxJjAkBgNVBAMMHW9hdXRoLXJl
565c291cmNlLXNlcnZlciB0ZXN0IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE
566e7My2gfib5QnEAeGyAJKjT2GdFWSr/gsJp9Qt89ft6HM3x/OpSD4QcJCYX9PYiHV
567W6SHYgHp4WFVjGM9qjmoraNjMGEwHwYDVR0jBBgwFoAU9eooe6hx3H8ZdYPDWBk9
568aL6DwgYwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE
569FPXqKHuocdx/GXWDw1gZPWi+g8IGMAoGCCqGSM49BAMCA0gAMEUCICqXMiIGLPEt
5707US8NxHfZFnG0C9Vm8OpblVteFqhgC35AiEAkX4NND+zxhsgLubA/IRLv8Y1X/UF
571uaPm9BLhB9Et/O8=
572-----END CERTIFICATE-----
573";
574    const TEST_SERVER_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
575MIIB1DCCAXmgAwIBAgIUQ9Ls7gwbeQhAuWSfkTYfsDchl6wwCgYIKoZIzj0EAwIw
576KDEmMCQGA1UEAwwdb2F1dGgtcmVzb3VyY2Utc2VydmVyIHRlc3QgQ0EwIBcNMjYw
577OTI5MjAxMjAyWhgPMjEyNjA5MDUyMDEyMDJaMBQxEjAQBgNVBAMMCWxvY2FsaG9z
578dDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBCXOskkxlIBL+lhnOrlrPu1L9gC
579jY+G7j8/szHLOJNsRDMgpxFmu6Xt23R6KqWvpxHbnPFAiokHl/7TkMBswWWjgZIw
580gY8wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB
581BQUHAwEwGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MB0GA1UdDgQWBBSzR7DI
582xRoRBT2TR7C6hIp0ZIjywTAfBgNVHSMEGDAWgBT16ih7qHHcfxl1g8NYGT1ovoPC
583BjAKBggqhkjOPQQDAgNJADBGAiEA1zI6rId65FWP1wGk6HgvZM6luKCVqMKrpALd
584FvufkWECIQD9fFokhSzPz7PDudHrdBF3qa/53rK4XeyPR1eSxhBksA==
585-----END CERTIFICATE-----
586";
587    const TEST_SERVER_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----
588MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgAh1xbGWuQST6HGs7
589d7BfjO2Q7Vr/U9DdDr6Mr8l/olKhRANCAAQQlzrJJMZSAS/pYZzq5az7tS/YAo2P
590hu4/P7MxyziTbEQzIKcRZrul7dt0eiqlr6cR25zxQIqJB5f+05DAbMFl
591-----END PRIVATE KEY-----
592";
593
594    const SECRETS: [&str; 3] = ["alice", "s3cret", "t0ken"];
595
596    fn assert_no_secret(text: &str) {
597        for secret in SECRETS {
598            assert!(!text.contains(secret), "{secret:?} leaked into: {text}");
599        }
600    }
601
602    /// An https server on 127.0.0.1 presenting [`TEST_SERVER_CERT_PEM`] and
603    /// answering every request with `body`. Returns its JWKS URL and how many
604    /// requests got through the TLS handshake.
605    async fn spawn_https_jwks(body: String) -> (String, Arc<AtomicUsize>) {
606        spawn_https("200 OK\r\nContent-Type: application/json", body).await
607    }
608
609    /// [`spawn_https_jwks`] answering with the status line and extra headers
610    /// `head` (`"302 Found\r\nLocation: ..."`, say).
611    async fn spawn_https(head: &'static str, body: String) -> (String, Arc<AtomicUsize>) {
612        let certs: Vec<CertificateDer<'static>> =
613            CertificateDer::pem_slice_iter(TEST_SERVER_CERT_PEM.as_bytes())
614                .collect::<Result<_, _>>()
615                .unwrap();
616        let key = PrivateKeyDer::from_pem_slice(TEST_SERVER_KEY_PEM.as_bytes()).unwrap();
617        let config = rustls::ServerConfig::builder_with_provider(Arc::new(
618            rustls::crypto::ring::default_provider(),
619        ))
620        .with_safe_default_protocol_versions()
621        .unwrap()
622        .with_no_client_auth()
623        .with_single_cert(certs, key)
624        .unwrap();
625        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
626        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
627        let port = listener.local_addr().unwrap().port();
628        let hits = Arc::new(AtomicUsize::new(0));
629        let counter = Arc::clone(&hits);
630        tokio::spawn(async move {
631            while let Ok((sock, _)) = listener.accept().await {
632                let acceptor = acceptor.clone();
633                let body = body.clone();
634                let counter = Arc::clone(&counter);
635                tokio::spawn(async move {
636                    let Ok(mut tls) = acceptor.accept(sock).await else {
637                        return;
638                    };
639                    let mut buf = Vec::new();
640                    let mut tmp = [0u8; 4096];
641                    while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
642                        match tls.read(&mut tmp).await {
643                            Ok(0) | Err(_) => return,
644                            Ok(n) => buf.extend_from_slice(&tmp[..n]),
645                        }
646                    }
647                    counter.fetch_add(1, Ordering::SeqCst);
648                    let resp = format!(
649                        "HTTP/1.1 {head}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
650                        body.len()
651                    );
652                    let _ = tls.write_all(resp.as_bytes()).await;
653                    let _ = tls.shutdown().await;
654                });
655            }
656        });
657        (format!("https://127.0.0.1:{port}/jwks"), hits)
658    }
659
660    #[tokio::test]
661    async fn the_builder_with_no_options_is_new() {
662        let server = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
663        let cfg = testing::resolved_config(&server.url);
664        let built = OAuthValidator::builder(&cfg).build().unwrap();
665        let new = OAuthValidator::new(&cfg).unwrap();
666        assert_eq!(format!("{built:?}"), format!("{new:?}"));
667        assert_eq!(built.config(), new.config());
668        assert_eq!(built.metadata(), new.metadata());
669        assert_eq!(built.metadata_path(), new.metadata_path());
670        assert_eq!(
671            built.invalid_token_challenge(),
672            new.invalid_token_challenge()
673        );
674        assert_eq!(
675            built.insufficient_scope_challenge(),
676            new.insufficient_scope_challenge()
677        );
678        assert_eq!(built.key_set_status(), new.key_set_status());
679        assert!(!built.is_ready());
680
681        let defaults = OAuthValidator::builder(&cfg).fetch_settings().unwrap();
682        assert_eq!(defaults.timeout, DEFAULT_FETCH_TIMEOUT);
683        assert!(defaults.roots.is_empty());
684        assert!(defaults.proxy.is_none());
685        assert!(
686            OAuthValidator::builder(&cfg)
687                .seed_keys()
688                .unwrap()
689                .is_empty()
690        );
691
692        for v in [&built, &new] {
693            v.validate(&testing::valid_token()).await.unwrap();
694        }
695        assert_eq!(server.hits.load(Ordering::SeqCst), 2);
696    }
697
698    #[tokio::test]
699    async fn a_private_ca_root_lets_the_tls_fetch_succeed_and_its_absence_fails_closed() {
700        let (url, hits) = spawn_https_jwks(testing::jwks_body()).await;
701        let cfg = testing::resolved_config(&url);
702
703        let without = OAuthValidator::new(&cfg).unwrap();
704        let err = without.refresh_now().await.unwrap_err();
705        assert_eq!(err.kind(), RefreshErrorKind::Fetch, "{err}");
706        assert!(!without.is_ready());
707        assert!(without.validate(&testing::valid_token()).await.is_err());
708        assert_eq!(
709            hits.load(Ordering::SeqCst),
710            0,
711            "no request past the handshake"
712        );
713
714        let with = OAuthValidator::builder(&cfg)
715            .add_root_certificate_pem(TEST_CA_PEM.as_bytes())
716            .build()
717            .unwrap();
718        assert_eq!(with.refresh_now().await.unwrap(), 1);
719        with.validate(&testing::valid_token()).await.unwrap();
720        assert_eq!(hits.load(Ordering::SeqCst), 1);
721    }
722
723    /// End to end: an https key fetch redirected to plain http is refused —
724    /// loopback target or not, opt-in or not — and the plain-http URL is
725    /// never requested.
726    #[tokio::test]
727    async fn an_https_fetch_redirected_to_plain_http_is_refused() {
728        let plain = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
729        let head: &'static str =
730            Box::leak(format!("302 Found\r\nLocation: {}", plain.url).into_boxed_str());
731        let (url, hits) = spawn_https(head, String::new()).await;
732        for allow_insecure_http in [false, true] {
733            let mut cfg = testing::resolved_config(&url);
734            cfg.allow_insecure_http = allow_insecure_http;
735            let v = OAuthValidator::builder(&cfg)
736                .add_root_certificate_pem(TEST_CA_PEM.as_bytes())
737                .build()
738                .unwrap();
739            let err = v.refresh_now().await.unwrap_err();
740            assert_eq!(err.kind(), RefreshErrorKind::Fetch);
741            assert!(
742                err.to_string()
743                    .contains("redirect from https to a non-https URL refused"),
744                "{err}"
745            );
746        }
747        assert_eq!(hits.load(Ordering::SeqCst), 2, "the https URL was asked");
748        assert_eq!(plain.hits.load(Ordering::SeqCst), 0, "the http one never");
749    }
750
751    #[tokio::test]
752    async fn a_pem_bundle_and_repeated_calls_add_every_certificate() {
753        let (url, _) = spawn_https_jwks(testing::jwks_body()).await;
754        let cfg = testing::resolved_config(&url);
755        // The CA second in a bundle, after an unrelated certificate.
756        let bundle = format!("{TEST_SERVER_CERT_PEM}{TEST_CA_PEM}");
757        let v = OAuthValidator::builder(&cfg)
758            .add_root_certificate_pem(bundle.as_bytes())
759            .build()
760            .unwrap();
761        assert_eq!(v.refresh_now().await.unwrap(), 1);
762        // The CA in a second call.
763        let v = OAuthValidator::builder(&cfg)
764            .add_root_certificate_pem(TEST_SERVER_CERT_PEM.as_bytes())
765            .add_root_certificate_pem(TEST_CA_PEM.as_bytes())
766            .build()
767            .unwrap();
768        assert_eq!(v.refresh_now().await.unwrap(), 1);
769        let settings = OAuthValidator::builder(&cfg)
770            .add_root_certificate_pem(bundle.as_bytes())
771            .add_root_certificate_pem(TEST_CA_PEM.as_bytes())
772            .fetch_settings()
773            .unwrap();
774        assert_eq!(settings.roots.len(), 3);
775    }
776
777    #[test]
778    fn an_unusable_root_pem_is_a_build_error() {
779        let cfg = testing::resolved_config("https://idp.example.test/jwks");
780        for (pem, what) in [
781            ("", "empty"),
782            ("not a pem at all", "no PEM block"),
783            (TEST_SERVER_KEY_PEM, "a private key, no certificate"),
784            (
785                &format!("{TEST_CA_PEM}{TEST_SERVER_KEY_PEM}"),
786                "a certificate with a private key beside it",
787            ),
788            (
789                &format!(
790                    "{TEST_CA_PEM}-----BEGIN EC PRIVATE KEY-----\nAAAA\n-----END EC PRIVATE KEY-----\n"
791                ),
792                "a certificate with a legacy private-key block beside it",
793            ),
794            (
795                "-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n",
796                "bad base64",
797            ),
798            (
799                "-----BEGIN CERTIFICATE-----\nAAAAAAAA\n-----END CERTIFICATE-----\n",
800                "valid base64, not a certificate",
801            ),
802        ] {
803            let err = OAuthValidator::builder(&cfg)
804                .add_root_certificate_pem(TEST_CA_PEM.as_bytes())
805                .add_root_certificate_pem(pem.as_bytes())
806                .build()
807                .unwrap_err();
808            assert!(
809                matches!(err, ValidatorError::InvalidRootCertificate { index: 1, .. }),
810                "{what}: {err:?}"
811            );
812        }
813    }
814
815    #[tokio::test]
816    async fn fetches_go_through_the_proxy_and_its_credential_never_shows() {
817        // A plain-HTTP forward proxy sees the absolute-form request target.
818        // `jwks.example.test` never resolves, so a success can only have come
819        // through the proxy.
820        let target = "http://jwks.example.test/jwks";
821        let proxy_server = testing::spawn_http_server(
822            HashMap::from([(target.to_string(), ("200 OK", testing::jwks_body()))]),
823            None,
824        )
825        .await;
826        let mut cfg = testing::resolved_config(target);
827        cfg.allow_insecure_http = true;
828        let proxy_url = proxy_server
829            .base
830            .replacen("://", "://alice:s3cret@", 1)
831            .to_string();
832        let builder = OAuthValidator::builder(&cfg).proxy(&proxy_url);
833        assert_no_secret(&format!("{builder:?}"));
834        let v = builder.build().unwrap();
835        assert_eq!(v.refresh_now().await.unwrap(), 1);
836        assert_eq!(proxy_server.hits.load(Ordering::SeqCst), 1);
837        v.validate(&testing::valid_token()).await.unwrap();
838        assert_no_secret(&format!("{v:?} {:?}", v.key_set_status()));
839
840        // A dead proxy fails the fetch closed, and the error does not name it.
841        let v = OAuthValidator::builder(&cfg)
842            .proxy("http://alice:s3cret@127.0.0.2:1")
843            .build()
844            .unwrap();
845        let err = v.refresh_now().await.unwrap_err();
846        assert_eq!(err.kind(), RefreshErrorKind::Fetch);
847        assert_no_secret(&format!("{err} {err:?} {:?}", v.key_set_status()));
848    }
849
850    #[tokio::test]
851    async fn a_loopback_target_bypasses_the_proxy() {
852        let server = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
853        for host in ["127.0.0.1", "localhost"] {
854            let cfg = testing::resolved_config(&server.url.replace("127.0.0.1", host));
855            // Nothing listens on the proxy's port: used, it would fail.
856            let v = OAuthValidator::builder(&cfg)
857                .proxy("http://proxy.example.test:1")
858                .build()
859                .unwrap();
860            assert_eq!(v.refresh_now().await.unwrap(), 1, "{host}");
861        }
862    }
863
864    #[test]
865    fn a_malformed_proxy_url_is_refused_without_showing_it() {
866        let cfg = testing::resolved_config("https://idp.example.test/jwks");
867        for proxy in [
868            "",
869            "not a url",
870            "proxy.example.test:3128",
871            "socks5://alice:s3cret@proxy.example.test:1080",
872            "ftp://alice:s3cret@proxy.example.test",
873            "http://alice:s3cret@proxy.example.test:3128/path",
874            "http://alice:s3cret@proxy.example.test:3128/?key=t0ken",
875            "http://alice:s3cret@proxy.example.test:3128/#t0ken",
876            "http://alice:s3cret@proxy example.test:3128",
877            " http://alice:s3cret@proxy.example.test:3128",
878            "http://alice:s3cret@proxy.éxample.test:3128",
879            // Parses as host `alice`, port `1234`, path `/s3cret@proxy…`.
880            "http://alice:1234/s3cret@proxy.example.test:3128",
881            "http://proxy.example.test:3128/alice:s3cret@x",
882            "http://proxy.example.test:3128/?u=alice:s3cret@x",
883            // A credential over plain http to a non-loopback proxy, in every
884            // spelling the URL parser normalizes to `http://`.
885            "http://alice:s3cret@proxy.example.test:3128",
886            "http:/alice:s3cret@proxy.example.test:3128",
887            "http:alice:s3cret@proxy.example.test:3128",
888            "HTTP:\\\\alice:s3cret@proxy.example.test:3128",
889            "HtTp://alice:s3cret@proxy.example.test:3128",
890        ] {
891            let err = OAuthValidator::builder(&cfg)
892                .proxy(proxy)
893                .build()
894                .unwrap_err();
895            assert!(
896                matches!(err, ValidatorError::InvalidProxy { .. }),
897                "{proxy:?}: {err:?}"
898            );
899            assert_no_secret(&format!("{err} {err:?}"));
900            let ValidatorError::InvalidProxy { proxy: shown, .. } = &err else {
901                unreachable!()
902            };
903            assert!(
904                shown == "<redacted>" || shown.ends_with("://<redacted>"),
905                "{proxy:?} shown as {shown:?}"
906            );
907            assert!(
908                !shown.contains("1234") && !shown.contains("example"),
909                "{shown}"
910            );
911        }
912        let err = OAuthValidator::builder(&cfg)
913            .proxy("http://alice:s3cret@proxy.example.test:3128")
914            .build()
915            .unwrap_err();
916        assert!(
917            err.to_string().contains("mcp.oauth.allow_insecure_http"),
918            "{err}"
919        );
920        // With the opt-in, a credentialed plain-http proxy is accepted (and
921        // warned about).
922        let mut insecure = cfg.clone();
923        insecure.allow_insecure_http = true;
924        assert!(
925            OAuthValidator::builder(&insecure)
926                .proxy("http://alice:s3cret@proxy.example.test:3128")
927                .build()
928                .is_ok()
929        );
930        for proxy in [
931            "http://proxy.example.test:3128",
932            "http://proxy.example.test:3128/",
933            "https://alice:s3cret@proxy.example.test:8443",
934            "http://alice:s3cret@127.0.0.1:3128",
935            "http://[::1]:3128",
936        ] {
937            assert!(
938                OAuthValidator::builder(&cfg).proxy(proxy).build().is_ok(),
939                "{proxy:?}"
940            );
941        }
942    }
943
944    #[test]
945    fn loopback_urls_get_the_proxy_free_client_and_others_the_normal_one() {
946        for proxy in [None, Some("http://proxy.example.test:3128")] {
947            let mut builder = OAuthValidator::builder(&testing::resolved_config(""));
948            if let Some(proxy) = proxy {
949                builder = builder.proxy(proxy);
950            }
951            let clients =
952                crate::jwks::http_clients(false, "opt-in", &builder.fetch_settings().unwrap())
953                    .unwrap();
954            for url in [
955                "http://localhost/jwks",
956                "http://LOCALHOST:9000/jwks",
957                "https://idp.localhost/.well-known/openid-configuration",
958                "http://127.0.0.1:9000/jwks",
959                "http://127.10.20.30/jwks",
960                "http://[::1]:9000/jwks",
961                // Non-canonical spellings are judged as parsed, as `resolve` judges them.
962                "http:/localhost:9000/jwks",
963                "HTTP:\\\\127.0.0.1\\jwks",
964                " http://[::1]/jwks",
965            ] {
966                assert!(
967                    std::ptr::eq(clients.for_url(url), &clients.loopback),
968                    "{url} with proxy {proxy:?}"
969                );
970            }
971            for url in [
972                "https://idp.example.test/jwks",
973                "http://203.0.113.1/jwks",
974                "http://localhost.example.test/jwks",
975                "http://[::2]/jwks",
976                "http://198.51.100.1/jwks",
977                "not a url",
978                "http:/idp.example.test/jwks",
979                "http:localhost.example.test/jwks",
980                "HTTP:\\\\idp.example.test\\jwks",
981            ] {
982                assert!(
983                    std::ptr::eq(clients.for_url(url), &clients.normal),
984                    "{url} with proxy {proxy:?}"
985                );
986            }
987            // Client choice and `resolve`'s plain-http judgment agree for
988            // every http spelling: loopback client exactly when no opt-in is
989            // needed.
990            for url in [
991                "http:/localhost/jwks",
992                "http:127.0.0.1/jwks",
993                "http:/idp.example.test/jwks",
994                " http://idp.example.test/jwks",
995                "HTTP:\\\\[::1]\\jwks",
996            ] {
997                assert_eq!(
998                    std::ptr::eq(clients.for_url(url), &clients.loopback),
999                    !crate::validator::plain_http_non_loopback(url),
1000                    "{url}"
1001                );
1002            }
1003        }
1004    }
1005
1006    #[test]
1007    fn the_fetch_timeout_is_bounded() {
1008        let cfg = testing::resolved_config("https://idp.example.test/jwks");
1009        for timeout in [
1010            Duration::ZERO,
1011            Duration::from_millis(999),
1012            Duration::from_secs(61),
1013            Duration::MAX,
1014        ] {
1015            let err = OAuthValidator::builder(&cfg)
1016                .fetch_timeout(timeout)
1017                .build()
1018                .unwrap_err();
1019            assert!(
1020                matches!(err, ValidatorError::FetchTimeoutOutOfRange { .. }),
1021                "{timeout:?}: {err:?}"
1022            );
1023        }
1024        for timeout in [MIN_FETCH_TIMEOUT, MAX_FETCH_TIMEOUT, Duration::from_secs(5)] {
1025            let settings = OAuthValidator::builder(&cfg)
1026                .fetch_timeout(timeout)
1027                .fetch_settings()
1028                .unwrap();
1029            assert_eq!(settings.timeout, timeout);
1030        }
1031    }
1032
1033    #[tokio::test]
1034    async fn the_fetch_timeout_is_honored() {
1035        let server = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
1036        server.hold.store(true, Ordering::SeqCst);
1037        let v = OAuthValidator::builder(&testing::resolved_config(&server.url))
1038            .fetch_timeout(MIN_FETCH_TIMEOUT)
1039            .build()
1040            .unwrap();
1041        let started = std::time::Instant::now();
1042        let err = v.refresh_now().await.unwrap_err();
1043        let elapsed = started.elapsed();
1044        assert_eq!(err.kind(), RefreshErrorKind::Fetch, "{err}");
1045        assert!(elapsed >= MIN_FETCH_TIMEOUT, "{elapsed:?}");
1046        assert!(elapsed < DEFAULT_FETCH_TIMEOUT / 2, "{elapsed:?}");
1047        assert_eq!(server.hits.load(Ordering::SeqCst), 1);
1048    }
1049
1050    #[tokio::test]
1051    async fn seeded_keys_validate_without_a_fetch() {
1052        let server = testing::spawn_jwks_server("200 OK", testing::jwks_body()).await;
1053        let v = OAuthValidator::builder(&testing::resolved_config(&server.url))
1054            .initial_jwks(&testing::jwks_body())
1055            .build()
1056            .unwrap();
1057        let status = v.key_set_status();
1058        assert_eq!(status.keys, 1);
1059        assert!(v.is_ready() && status.is_ready());
1060        assert_eq!(status.last_attempt, None);
1061        assert_eq!(status.last_success, None);
1062        assert_eq!(status.last_error, None);
1063        assert_eq!(status.jwks_uri.as_deref(), Some(server.url.as_str()));
1064        v.validate(&testing::valid_token()).await.unwrap();
1065        assert_eq!(server.hits.load(Ordering::SeqCst), 0);
1066    }
1067
1068    #[tokio::test]
1069    async fn a_refresh_replaces_seeded_keys_and_a_failed_one_keeps_them() {
1070        // The authorization server now publishes only the EC key.
1071        let server =
1072            testing::spawn_jwks_server("200 OK", testing::jwks_of(&[testing::jwk_ec()])).await;
1073        let v = OAuthValidator::builder(&testing::resolved_config(&server.url))
1074            .initial_jwks(&testing::jwks_body())
1075            .build()
1076            .unwrap();
1077
1078        // A failed refresh keeps the seed.
1079        server
1080            .routes
1081            .lock()
1082            .unwrap()
1083            .insert("/jwks".into(), ("503 Service Unavailable", "{}".into()));
1084        assert!(v.refresh_now().await.is_err());
1085        let status = v.key_set_status();
1086        assert_eq!(status.keys, 1);
1087        assert!(status.last_attempt.is_some());
1088        assert_eq!(status.last_success, None);
1089        v.validate(&testing::valid_token()).await.unwrap();
1090
1091        // A successful one replaces it: the seeded RSA key is gone.
1092        server.routes.lock().unwrap().clear();
1093        assert_eq!(v.refresh_now().await.unwrap(), 1);
1094        let status = v.key_set_status();
1095        assert!(status.last_success.is_some());
1096        assert_eq!(status.last_error, None);
1097        assert!(v.validate(&testing::valid_token()).await.is_err());
1098    }
1099
1100    #[test]
1101    fn seeded_keys_are_narrowed_exactly_like_fetched_ones() {
1102        let cfg = testing::resolved_config("https://idp.example.test/jwks");
1103        let oct = json!({"kty": "oct", "kid": "hmac", "k": "c2VjcmV0"});
1104        let mut enc = testing::jwk_rsa_a();
1105        enc["use"] = json!("enc");
1106        let mut encrypt_only = testing::jwk_rsa_a();
1107        encrypt_only["key_ops"] = json!(["encrypt"]);
1108
1109        let refused = |json: &str| {
1110            let err = OAuthValidator::builder(&cfg)
1111                .initial_jwks(json)
1112                .build()
1113                .unwrap_err();
1114            assert!(
1115                matches!(err, ValidatorError::InvalidInitialJwks { .. }),
1116                "{err:?}"
1117            );
1118            err.to_string()
1119        };
1120        // No usable key at all.
1121        for keys in [
1122            vec![oct.clone()],
1123            vec![enc.clone()],
1124            vec![encrypt_only.clone()],
1125            vec![oct.clone(), enc.clone(), encrypt_only.clone()],
1126        ] {
1127            let err = refused(&testing::jwks_of(&keys));
1128            assert!(err.contains("no usable signature keys"), "{err}");
1129        }
1130        // Past the key cap: a usable key at position 65 is never considered.
1131        let mut over_cap = vec![oct.clone(); crate::jwks::MAX_JWKS_KEYS];
1132        over_cap.push(testing::jwk_rsa_a());
1133        refused(&testing::jwks_of(&over_cap));
1134        // Over the size cap, not JSON, not a JWK Set.
1135        let padded = format!(
1136            "{{\"pad\":\"{}\",\"keys\":[{}]}}",
1137            "x".repeat(crate::jwks::MAX_FETCH_BYTES),
1138            testing::jwk_rsa_a()
1139        );
1140        assert!(refused(&padded).contains("byte cap"));
1141        assert!(refused("{not json").contains("not JSON"));
1142        assert!(refused(r#"{"keys": {}}"#).contains("not a JWK Set"));
1143        // A key outside the configured algorithms.
1144        let mut es_only = cfg.clone();
1145        es_only.algorithms = vec![crate::Algorithm::ES256];
1146        assert!(
1147            OAuthValidator::builder(&es_only)
1148                .initial_jwks(&testing::jwks_body())
1149                .build()
1150                .is_err()
1151        );
1152
1153        // Unusable entries are skipped one at a time, the usable one kept —
1154        // including at the last position under the cap.
1155        let mut mixed = vec![oct.clone(); crate::jwks::MAX_JWKS_KEYS - 3];
1156        mixed.extend([enc, encrypt_only, testing::jwk_rsa_a()]);
1157        let v = OAuthValidator::builder(&cfg)
1158            .initial_jwks(&testing::jwks_of(&mixed))
1159            .build()
1160            .unwrap();
1161        assert_eq!(v.key_set_status().keys, 1);
1162    }
1163
1164    #[test]
1165    fn debug_never_shows_the_proxy_or_the_seed() {
1166        let cfg = testing::resolved_config("https://idp.example.test/jwks");
1167        let builder = OAuthValidator::builder(&cfg)
1168            .proxy("https://alice:s3cret@proxy.example.test:8443")
1169            .initial_jwks(&testing::jwks_body())
1170            .add_root_certificate_pem(TEST_CA_PEM.as_bytes());
1171        let shown = format!("{builder:?}");
1172        assert_no_secret(&shown);
1173        assert!(!shown.contains(testing::N_A), "{shown}");
1174        assert!(shown.contains("root_certificate_pems: 1"), "{shown}");
1175    }
1176}