1use 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#[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 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 pub fn add_root_certificate_pem(mut self, pem: &[u8]) -> Self {
162 self.root_pems.push(pem.to_vec());
163 self
164 }
165
166 pub fn proxy(mut self, url: impl Into<String>) -> Self {
231 self.proxy = Some(url.into());
232 self
233 }
234
235 pub fn fetch_timeout(mut self, timeout: Duration) -> Self {
276 self.fetch_timeout = timeout;
277 self
278 }
279
280 pub fn initial_jwks(mut self, json: &str) -> Self {
346 self.initial_jwks = Some(json.to_string());
347 self
348 }
349
350 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 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 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
430fn parse_root_pem(pem: &[u8]) -> Result<Vec<reqwest::Certificate>, String> {
438 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
464fn 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 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 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 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 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 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 #[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 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 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 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 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 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 "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 "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 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 "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 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 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 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 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 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 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 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 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 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}