1use 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#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum ValidatorError {
30 #[error("{section}: no accepted audience configured")]
33 #[non_exhaustive]
34 NoAudience {
35 section: String,
37 },
38 #[error("{key} is empty")]
40 #[non_exhaustive]
41 NoAlgorithms {
42 key: String,
44 },
45 #[error("{key} {leeway_secs} is over the {max}-second cap")]
50 #[non_exhaustive]
51 LeewayTooLarge {
52 key: String,
54 leeway_secs: u64,
56 max: u64,
58 },
59 #[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
69pub(crate) enum CachedAttempt {
71 Decided(Result<AuthorizedToken, TokenRejection>),
74 NeedsKeyFetch,
77}
78
79pub struct OAuthValidator {
123 config: ResolvedOAuthConfig,
126 resource_metadata_url: String,
129 metadata_path: String,
132 supported_scopes: String,
134 required_scopes: String,
136 metadata: Value,
138 jwt_algorithms: Vec<jsonwebtoken::Algorithm>,
140 validation: Validation,
147 keys: Arc<JwksStore>,
150 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 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 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 validation.set_issuer(&[&config.issuer]);
265 validation.set_audience(&audiences);
272 validation.set_required_spec_claims(&["exp", "iss", "aud"]);
277 validation.leeway = config.leeway_secs;
278 validation.validate_exp = true;
279 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 let supported_scopes = if config.scopes_supported.is_empty() {
293 required_scopes.clone()
294 } else {
295 config.scopes_supported.join(" ")
296 };
297
298 let unadvertised = unadvertised_scopes(config);
305 if !unadvertised.is_empty() {
306 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 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 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 pub fn config(&self) -> &ResolvedOAuthConfig {
402 &self.config
403 }
404
405 pub fn resource(&self) -> &str {
407 &self.config.resource
408 }
409
410 pub fn resource_metadata_url(&self) -> &str {
414 &self.resource_metadata_url
415 }
416
417 pub fn metadata_path(&self) -> &str {
429 &self.metadata_path
430 }
431
432 pub fn metadata(&self) -> &Value {
436 &self.metadata
437 }
438
439 pub fn invalid_token_challenge(&self) -> String {
455 challenge::invalid_token(&self.resource_metadata_url, &self.supported_scopes)
456 }
457
458 pub fn insufficient_scope_challenge(&self) -> String {
468 challenge::insufficient_scope(&self.required_scopes, &self.resource_metadata_url)
469 }
470
471 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 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 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 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 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 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 TokenRejection::Invalid(format!("token rejected: {e}"))
645 })?;
646 let claims = data.claims;
647
648 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 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 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 if !self
694 .config
695 .required_scopes
696 .iter()
697 .all(|required| scopes.contains(required))
698 {
699 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 pub async fn refresh_now(&self) -> Result<usize, RefreshError> {
740 self.keys.refresh_now().await
741 }
742
743 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 drop(this);
809 if tokio::time::timeout(wait, alive.changed()).await.is_ok() {
810 return;
811 }
812 }
813 })
814 }
815}
816
817struct CheckedHeader {
820 kid: Option<String>,
821 alg: Algorithm,
822}
823
824fn 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
849fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861enum UnscopedPosture {
862 Scoped,
865 UnscopedButTypEnforced,
868 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
881fn 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
896pub(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
903pub(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 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 #[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 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 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 #[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 #[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 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 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 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 assert!(
1146 v.metadata().get("scopes_supported").is_none(),
1147 "{}",
1148 v.metadata()
1149 );
1150
1151 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 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 #[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 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 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 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 #[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 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 #[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(¬_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(¬_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 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 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 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 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 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 #[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 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 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 #[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 #[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 #[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 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 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 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 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 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 #[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 #[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 "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 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 #[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 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 #[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 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 #[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 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 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 #[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 #[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); 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 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 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 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 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 #[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); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 #[tokio::test]
2594 async fn documented_shape_fixture_not_live_tested_keycloak() {
2595 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 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 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 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 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 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 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 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 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 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}