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::{Instrument, Span, debug, error, info, warn};
13
14use crate::algorithms::Algorithm;
15use crate::builder::OAuthValidatorBuilder;
16use crate::challenge;
17use crate::config::ResolvedOAuthConfig;
18use crate::jwks::{
19 JWKS_BACKGROUND_REFRESH_INTERVAL, JwksStore, KeySetStatus, RefreshError,
20 background_retry_delay, http_clients, keyless_retry_delay, redact_url,
21};
22use crate::observe::record_field;
23use crate::token::{
24 AuthorizedToken, InvalidTokenKind, MAX_TOKEN_BYTES, TokenRejection, check_typ, client_id_of,
25 extract_principal, extract_scopes, for_log, for_log_field, missing_scopes, numeric_date_secs,
26};
27
28macro_rules! validation_span {
35 ($name:literal) => {
36 tracing::debug_span!(
37 $name,
38 kid = tracing::field::Empty,
39 alg = tracing::field::Empty,
40 auth.outcome = tracing::field::Empty,
41 auth.reason = tracing::field::Empty,
42 )
43 };
44}
45
46fn record_validation(span: &Span, refusal: Option<&TokenRejection>) {
49 match refusal {
50 None => {
51 record_field(span, "auth.outcome", "accepted");
52 }
53 Some(rejection) => {
54 record_field(span, "auth.outcome", "rejected");
55 record_field(span, "auth.reason", crate::observe::reason(rejection));
56 }
57 }
58}
59
60#[derive(Debug, thiserror::Error)]
62#[non_exhaustive]
63pub enum ValidatorError {
64 #[error("{section}: no accepted audience configured")]
67 #[non_exhaustive]
68 NoAudience {
69 section: String,
71 },
72 #[error("{key} is empty")]
74 #[non_exhaustive]
75 NoAlgorithms {
76 key: String,
78 },
79 #[error("{key} {leeway_secs} is over the {max}-second cap")]
84 #[non_exhaustive]
85 LeewayTooLarge {
86 key: String,
88 leeway_secs: u64,
90 max: u64,
92 },
93 #[error("Failed to build the HTTP client for OAuth metadata/JWKS fetches")]
100 HttpClient(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
101 #[error("fetch timeout {timeout:?} is outside the accepted {min:?}..={max:?}")]
104 #[non_exhaustive]
105 FetchTimeoutOutOfRange {
106 timeout: Duration,
108 min: Duration,
110 max: Duration,
112 },
113 #[error("root certificate PEM #{index} is unusable: {reason}")]
117 #[non_exhaustive]
118 InvalidRootCertificate {
119 index: usize,
122 reason: String,
125 },
126 #[error("proxy {proxy} is refused: {reason}")]
138 #[non_exhaustive]
139 InvalidProxy {
140 proxy: String,
142 reason: String,
144 },
145 #[error("initial JWKS is unusable: {reason}")]
149 #[non_exhaustive]
150 InvalidInitialJwks {
151 reason: String,
153 },
154}
155
156#[allow(clippy::large_enum_variant)]
159pub(crate) enum CachedAttempt {
160 Decided(Result<AuthorizedToken, TokenRejection>),
163 NeedsKeyFetch,
166}
167
168pub struct OAuthValidator {
212 config: ResolvedOAuthConfig,
215 resource_metadata_url: String,
218 metadata_path: String,
221 required_scopes: String,
223 invalid_token_challenge: String,
228 insufficient_scope_challenge: String,
229 #[cfg_attr(not(any(feature = "tower", test)), allow(dead_code))] challenge_fallback: bool,
233 metadata: Value,
235 jwt_algorithms: Vec<jsonwebtoken::Algorithm>,
237 validation: Validation,
244 keys: Arc<JwksStore>,
247 alive: tokio::sync::watch::Sender<()>,
251}
252
253impl std::fmt::Debug for OAuthValidator {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 f.debug_struct("OAuthValidator")
258 .field("issuer", &redact_url(&self.config.issuer))
259 .field("resource", &redact_url(&self.config.resource))
260 .field("required_scopes", &self.config.required_scopes)
261 .finish_non_exhaustive()
262 }
263}
264
265impl OAuthValidator {
266 pub fn new(config: &ResolvedOAuthConfig) -> Result<Self, ValidatorError> {
324 Self::builder(config).build()
325 }
326
327 pub fn builder(config: &ResolvedOAuthConfig) -> OAuthValidatorBuilder {
363 OAuthValidatorBuilder::new(config)
364 }
365
366 #[cfg(test)]
369 pub(crate) fn build(
370 config: &ResolvedOAuthConfig,
371 jwks_min_refetch_interval: Duration,
372 ) -> Result<Self, ValidatorError> {
373 Self::builder(config)
374 .min_refetch_interval(jwks_min_refetch_interval)
375 .build()
376 }
377
378 pub(crate) fn from_builder(builder: &OAuthValidatorBuilder) -> Result<Self, ValidatorError> {
382 let config = builder.config();
383 let naming = &config.key_naming;
384 let audiences = config.accepted_audiences();
393 if audiences.is_empty() {
394 return Err(ValidatorError::NoAudience {
395 section: naming.section(),
396 });
397 }
398 let Some(&first_alg) = config.algorithms.first() else {
399 return Err(ValidatorError::NoAlgorithms {
400 key: naming.key("algorithms"),
401 });
402 };
403 if config.leeway_secs > crate::config::MAX_LEEWAY_SECS {
404 return Err(ValidatorError::LeewayTooLarge {
405 key: naming.key("leeway_secs"),
406 leeway_secs: config.leeway_secs,
407 max: crate::config::MAX_LEEWAY_SECS,
408 });
409 }
410
411 let mut validation = Validation::new(first_alg.to_jwt());
412 validation.set_issuer(&[&config.issuer]);
416 validation.set_audience(&audiences);
423 validation.set_required_spec_claims(&["exp", "iss", "aud"]);
428 validation.leeway = config.leeway_secs;
429 validation.validate_exp = true;
430 validation.validate_nbf = true;
435 validation.validate_aud = true;
436
437 let resource_metadata_url = challenge::resource_metadata_url(&config.resource);
438 let metadata_path = challenge::metadata_path(&resource_metadata_url);
439 let required_scopes = config.required_scopes.join(" ");
440 let supported_scopes = if config.scopes_supported.is_empty() {
444 required_scopes.clone()
445 } else {
446 config.scopes_supported.join(" ")
447 };
448 let mut invalid_token_challenge =
454 challenge::invalid_token(&resource_metadata_url, &supported_scopes);
455 let mut insufficient_scope_challenge =
456 challenge::insufficient_scope(&required_scopes, &resource_metadata_url);
457 let challenge_fallback = !challenge::is_header_value(&invalid_token_challenge)
458 || !challenge::is_header_value(&insufficient_scope_challenge);
459 if challenge_fallback {
460 invalid_token_challenge = challenge::fallback("invalid_token", &supported_scopes);
461 insufficient_scope_challenge =
462 challenge::fallback("insufficient_scope", &required_scopes);
463 error!(
464 resource = %redact_url(&config.resource),
465 "the WWW-Authenticate challenge built from {}, {} and {} is not a valid HTTP \
466 header value (a control or non-ASCII character); sending a fallback \
467 challenge without resource_metadata, which clients need to find the \
468 authorization server. Fix the configuration (resolve refuses it).",
469 naming.key("resource"),
470 naming.key("scopes_supported"),
471 naming.key("required_scopes"),
472 );
473 }
474
475 let unadvertised = unadvertised_scopes(config);
482 if !unadvertised.is_empty() {
483 warn!(
487 unadvertised_scopes = %unadvertised.join(" "),
488 scopes_supported = ?config.scopes_supported,
489 "required scope(s) {} not in {} — clients that request the advertised \
490 scopes will get 403 insufficient_scope",
491 unadvertised.join(" "),
492 naming.key("scopes_supported")
493 );
494 }
495 match unscoped_posture(config) {
503 UnscopedPosture::Scoped => {}
504 UnscopedPosture::UnscopedButTypEnforced => info!(
505 "no required scope configured ({} and {} unset) — every valid access \
506 token (typ at+jwt) for the audience is accepted",
507 naming.key("required_scope"),
508 naming.key("required_scopes")
509 ),
510 UnscopedPosture::IdTokensAccepted => warn!(
511 "no required scope configured ({} and {} unset) and {} is off — ANY token \
512 this issuer signs for the audience is accepted, including an OIDC ID token \
513 minted for the same client. Set {} (a scope only access tokens carry) or \
514 turn on {} if the authorization server emits typ at+jwt.",
515 naming.key("required_scope"),
516 naming.key("required_scopes"),
517 naming.key("require_at_jwt"),
518 naming.key("required_scope"),
519 naming.key("require_at_jwt")
520 ),
521 }
522 if plain_http_non_loopback(&config.issuer) {
523 warn!(
524 issuer = %redact_url(&config.issuer),
525 "{} uses plain http on a non-loopback host — signing keys fetched over it \
526 can be substituted by anyone on the path. Use https.",
527 naming.key("issuer")
528 );
529 }
530 if plain_http_non_loopback(&config.resource) {
531 warn!(
532 resource = %redact_url(&config.resource),
533 "{} uses plain http on a non-loopback host — bearer tokens sent to it can \
534 be read in transit. Use https.",
535 naming.key("resource")
536 );
537 }
538 if let Some(jwks_uri) = config.jwks_uri.as_deref().map(str::trim)
539 && plain_http_non_loopback(jwks_uri)
540 {
541 warn!(
549 jwks_uri = %redact_url(jwks_uri),
550 "{} uses plain http on a non-loopback host — signing keys fetched over it \
551 can be substituted by anyone on the path. Use https.",
552 naming.key("jwks_uri")
553 );
554 }
555 for message in non_canonical_warnings(config) {
556 warn!("{message}");
557 }
558
559 for (claim, why) in required_claim_footguns(config) {
560 warn!(
561 claim = %for_log(claim),
562 "{} names {:?}: {} — it still works, but probably not as intended",
563 naming.key("required_claims"),
564 for_log(claim),
565 why
566 );
567 }
568
569 let metadata = challenge::metadata_document(config);
570 let settings = builder.fetch_settings()?;
571 let seed = builder.seed_keys()?;
572 let http = http_clients(
573 config.allow_insecure_http,
574 &naming.key("allow_insecure_http"),
575 &settings,
576 )
577 .map_err(|e| ValidatorError::HttpClient(Box::new(e)))?;
578
579 Ok(Self {
580 config: config.clone(),
581 resource_metadata_url,
582 metadata_path,
583 required_scopes,
584 invalid_token_challenge,
585 insufficient_scope_challenge,
586 challenge_fallback,
587 metadata,
588 jwt_algorithms: config.algorithms.iter().map(|a| a.to_jwt()).collect(),
589 validation,
590 keys: Arc::new(JwksStore::new(
591 config,
592 http,
593 builder.refetch_interval(),
594 seed,
595 )),
596 alive: tokio::sync::watch::channel(()).0,
597 })
598 }
599
600 pub fn config(&self) -> &ResolvedOAuthConfig {
602 &self.config
603 }
604
605 pub fn resource(&self) -> &str {
607 &self.config.resource
608 }
609
610 pub fn resource_metadata_url(&self) -> &str {
614 &self.resource_metadata_url
615 }
616
617 pub fn metadata_path(&self) -> &str {
629 &self.metadata_path
630 }
631
632 pub fn metadata(&self) -> &Value {
636 &self.metadata
637 }
638
639 pub fn invalid_token_challenge(&self) -> String {
660 self.invalid_token_challenge.clone()
661 }
662
663 pub fn insufficient_scope_challenge(&self) -> String {
676 self.insufficient_scope_challenge.clone()
677 }
678
679 pub fn insufficient_scope_challenge_for(
742 &self,
743 scopes: &[&str],
744 description: Option<&str>,
745 ) -> String {
746 let url = (!self.challenge_fallback).then_some(self.resource_metadata_url.as_str());
747 let challenge = challenge::insufficient_scope_for(scopes, url, description);
748 if challenge::is_header_value(&challenge) {
751 challenge
752 } else {
753 challenge::fallback("insufficient_scope", "")
754 }
755 }
756
757 pub(crate) fn scopes_with_floor<'a>(&'a self, extra: &[&'a str]) -> Vec<&'a str> {
761 let mut all: Vec<&str> = Vec::new();
762 for scope in self
763 .config
764 .required_scopes
765 .iter()
766 .map(String::as_str)
767 .chain(extra.iter().copied())
768 {
769 if !all.contains(&scope) {
770 all.push(scope);
771 }
772 }
773 all
774 }
775
776 #[cfg_attr(not(any(feature = "tower", test)), allow(dead_code))] pub(crate) fn challenge_fell_back(&self) -> bool {
780 self.challenge_fallback
781 }
782
783 pub async fn validate(&self, token: &str) -> Result<AuthorizedToken, TokenRejection> {
859 let span = validation_span!("oauth_rs.validate");
863 if span.is_disabled() {
864 return self.validate_in(token, &span).await;
865 }
866 let result = self
867 .validate_in(token, &span)
868 .instrument(span.clone())
869 .await;
870 record_validation(&span, result.as_ref().err());
871 result
872 }
873
874 async fn validate_in(
877 &self,
878 token: &str,
879 span: &Span,
880 ) -> Result<AuthorizedToken, TokenRejection> {
881 let header = self.check_header(token, span)?;
882 let key = self
883 .keys
884 .decoding_key(header.kid.as_deref(), header.alg)
885 .await?;
886 self.verify(token, header.alg, &key)
887 }
888
889 pub(crate) async fn validate_cached(&self, token: &str) -> CachedAttempt {
898 let span = validation_span!("oauth_rs.validate_cached");
899 if span.is_disabled() {
900 return self.validate_cached_in(token, &span).await;
901 }
902 let attempt = self
903 .validate_cached_in(token, &span)
904 .instrument(span.clone())
905 .await;
906 match &attempt {
907 CachedAttempt::Decided(result) => record_validation(&span, result.as_ref().err()),
908 CachedAttempt::NeedsKeyFetch => {
909 record_field(&span, "auth.outcome", "needs_key_fetch");
910 }
911 }
912 attempt
913 }
914
915 async fn validate_cached_in(&self, token: &str, span: &Span) -> CachedAttempt {
917 let header = match self.check_header(token, span) {
918 Ok(header) => header,
919 Err(rejection) => return CachedAttempt::Decided(Err(rejection)),
920 };
921 match self
922 .keys
923 .cached_decoding_key(header.kid.as_deref(), header.alg)
924 .await
925 {
926 Some(key) => CachedAttempt::Decided(self.verify(token, header.alg, &key)),
927 None => CachedAttempt::NeedsKeyFetch,
928 }
929 }
930
931 pub(crate) fn check_header(
940 &self,
941 token: &str,
942 span: &Span,
943 ) -> Result<CheckedHeader, TokenRejection> {
944 if token.is_empty() {
945 return Err(TokenRejection::Missing);
946 }
947 if token.len() > MAX_TOKEN_BYTES {
948 return Err(TokenRejection::invalid(
949 InvalidTokenKind::TooLarge,
950 format!(
951 "credential is {} bytes, over the {MAX_TOKEN_BYTES}-byte cap",
952 token.len()
953 ),
954 ));
955 }
956 if token.split('.').count() != 3 {
957 return Err(TokenRejection::invalid(
962 InvalidTokenKind::NotJwt,
963 "credential is not a JWT (a mistyped static token, or an opaque access \
964 token — this server validates JWT access tokens only; configure the \
965 authorization server to issue JWT access tokens)",
966 ));
967 }
968
969 let header = match decode_header(token) {
978 Ok(header) => header,
979 Err(e) => {
980 if !span.is_disabled() {
984 let (kid, alg) = raw_header_fields(token);
985 if let Some(kid) = kid {
986 record_field(span, "kid", for_log_field(&kid).as_str());
987 }
988 if let Some(alg) = alg {
989 record_field(span, "alg", for_log_field(&alg).as_str());
990 }
991 }
992 return Err(TokenRejection::invalid(
993 InvalidTokenKind::MalformedHeader,
994 format!("malformed token header: {}", for_log(&e.to_string())),
995 ));
996 }
997 };
998 if !span.is_disabled() {
999 if let Some(kid) = header.kid.as_deref() {
1000 record_field(span, "kid", for_log_field(kid).as_str());
1001 }
1002 match Algorithm::from_jwt(header.alg) {
1005 Some(alg) => record_field(span, "alg", alg.as_str()),
1006 None => {
1007 if let (_, Some(alg)) = raw_header_fields(token) {
1008 record_field(span, "alg", for_log_field(&alg).as_str());
1009 }
1010 }
1011 }
1012 }
1013 check_crit(token)?;
1014 if header.kid.is_none() && raw_header_has_member(token, "kid") {
1018 return Err(TokenRejection::invalid(
1019 InvalidTokenKind::MalformedHeader,
1020 "malformed token header: kid is present but not a string",
1021 ));
1022 }
1023 let alg = Algorithm::from_jwt(header.alg)
1024 .filter(|_| self.jwt_algorithms.contains(&header.alg))
1025 .ok_or_else(|| {
1026 TokenRejection::invalid(
1027 InvalidTokenKind::AlgorithmNotAllowed,
1028 format!(
1029 "token algorithm {:?} is not in {}",
1030 header.alg,
1031 self.config.key_naming.key("algorithms")
1032 ),
1033 )
1034 })?;
1035 check_typ(
1036 header.typ.as_deref(),
1037 self.config.require_at_jwt,
1038 &self.config.key_naming,
1039 )?;
1040 Ok(CheckedHeader {
1041 kid: header.kid,
1042 alg,
1043 })
1044 }
1045
1046 fn verify(
1051 &self,
1052 token: &str,
1053 alg: Algorithm,
1054 key: &DecodingKey,
1055 ) -> Result<AuthorizedToken, TokenRejection> {
1056 let mut validation = self.validation.clone();
1057 validation.algorithms = vec![alg.to_jwt()];
1058 let data = decode::<Map<String, Value>>(token, key, &validation).map_err(|e| {
1059 TokenRejection::invalid(
1063 decode_error_kind(e.kind(), token),
1064 format!("token rejected: {e}"),
1065 )
1066 })?;
1067 let claims = data.claims;
1068
1069 if claims.get("iss").and_then(Value::as_str) != Some(self.config.issuer.as_str()) {
1074 return Err(TokenRejection::invalid(
1075 InvalidTokenKind::WrongIssuer,
1076 format!(
1077 "token iss is not a single string equal to {}",
1078 self.config.key_naming.key("issuer")
1079 ),
1080 ));
1081 }
1082
1083 if let Some(nbf) = claims.get("nbf")
1089 && !nbf_is_numeric_date(nbf)
1090 {
1091 return Err(TokenRejection::invalid(
1092 InvalidTokenKind::MalformedClaim,
1093 "token nbf is not a NumericDate (a non-negative number of seconds)",
1094 ));
1095 }
1096
1097 if claims.contains_key("cnf") {
1104 return Err(TokenRejection::invalid(
1105 InvalidTokenKind::SenderConstrained,
1106 "token is sender-constrained (cnf); this server accepts bearer tokens only",
1107 ));
1108 }
1109
1110 self.check_claim_policy(&claims)?;
1115
1116 let scopes = extract_scopes(&claims, &self.config.scope_claims);
1117 let principal = extract_principal(&claims, &self.config.principal_claims);
1118 let subject = claims
1119 .get("sub")
1120 .and_then(Value::as_str)
1121 .map(str::to_string);
1122
1123 if !missing_scopes(
1128 &scopes,
1129 self.config.required_scopes.iter().map(String::as_str),
1130 )
1131 .is_empty()
1132 {
1133 info!(
1138 principal = ?principal.as_deref().map(for_log),
1139 required = %self.required_scopes,
1140 present = ?crate::token::scopes_for_log(&scopes),
1141 scope_claims = ?self.config.scope_claims,
1142 "OAuth token is valid but lacks the required scope"
1143 );
1144 return Err(TokenRejection::InsufficientScope);
1145 }
1146
1147 Ok(AuthorizedToken::from_verified_claims(
1148 claims, subject, principal, scopes,
1149 ))
1150 }
1151
1152 fn check_claim_policy(&self, claims: &Map<String, Value>) -> Result<(), TokenRejection> {
1156 let config = &self.config;
1157 if !config.allowed_client_ids.is_empty() {
1158 if let Some(raw) = claims.get("client_id")
1164 && raw.as_str().is_none_or(str::is_empty)
1165 {
1166 return Err(TokenRejection::invalid(
1167 InvalidTokenKind::ClientNotAllowed,
1168 format!(
1169 "token client_id is present but not a non-empty string, and {} is set",
1170 config.key_naming.key("allowed_client_ids")
1171 ),
1172 ));
1173 }
1174 match client_id_of(claims) {
1175 Some(client) if config.allowed_client_ids.iter().any(|c| c == client) => {}
1176 Some(client) => {
1177 return Err(TokenRejection::invalid(
1178 InvalidTokenKind::ClientNotAllowed,
1179 format!(
1180 "token client {:?} is not in {}",
1181 for_log(client),
1182 config.key_naming.key("allowed_client_ids")
1183 ),
1184 ));
1185 }
1186 None => {
1187 return Err(TokenRejection::invalid(
1188 InvalidTokenKind::ClientNotAllowed,
1189 format!(
1190 "token names no client (client_id or azp) and {} is set",
1191 config.key_naming.key("allowed_client_ids")
1192 ),
1193 ));
1194 }
1195 }
1196 }
1197
1198 if let Some(max_age) = config.max_token_age_secs {
1199 let key = config.key_naming.key("max_token_age_secs");
1200 let Some(iat) = claims.get("iat") else {
1201 return Err(TokenRejection::invalid(
1202 InvalidTokenKind::MissingClaim,
1203 format!("token has no iat and {key} is set"),
1204 ));
1205 };
1206 let Some(iat) = numeric_date_secs(iat) else {
1207 return Err(TokenRejection::invalid(
1208 InvalidTokenKind::MalformedClaim,
1209 "token iat is not a NumericDate (a non-negative number of seconds)",
1210 ));
1211 };
1212 let now = std::time::SystemTime::now()
1213 .duration_since(std::time::UNIX_EPOCH)
1214 .map_or(0, |d| d.as_secs());
1215 let leeway = config.leeway_secs;
1216 if iat > now.saturating_add(leeway) {
1217 return Err(TokenRejection::invalid(
1218 InvalidTokenKind::NotYetValid,
1219 format!("token iat is {}s in the future", iat - now),
1220 ));
1221 }
1222 let age = now.saturating_sub(iat);
1223 if age > max_age.saturating_add(leeway) {
1224 return Err(TokenRejection::invalid(
1225 InvalidTokenKind::TokenTooOld,
1226 format!("token was issued {age}s ago, over {key} {max_age}"),
1227 ));
1228 }
1229 }
1230
1231 for (name, required) in &config.required_claims {
1232 let key = config.key_naming.key("required_claims");
1233 match claims.get(name) {
1234 None => {
1235 return Err(TokenRejection::invalid(
1236 InvalidTokenKind::MissingClaim,
1237 format!(
1238 "token has no {:?} claim, which {key} requires",
1239 for_log(name)
1240 ),
1241 ));
1242 }
1243 Some(actual) if claim_matches(actual, required) => {}
1244 Some(_) => {
1245 return Err(TokenRejection::invalid(
1246 InvalidTokenKind::ClaimMismatch,
1247 format!("token claim {:?} does not match {key}", for_log(name)),
1248 ));
1249 }
1250 }
1251 }
1252 Ok(())
1253 }
1254
1255 pub async fn refresh_now(&self) -> Result<usize, RefreshError> {
1277 self.keys.refresh_now().await
1278 }
1279
1280 pub fn key_set_status(&self) -> KeySetStatus {
1342 self.keys.status()
1343 }
1344
1345 pub fn is_ready(&self) -> bool {
1383 self.keys.has_keys()
1384 }
1385
1386 pub fn spawn_background_refresh(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
1415 let weak = Arc::downgrade(self);
1416 let mut alive = self.alive.subscribe();
1417 tokio::spawn(async move {
1418 let mut first = true;
1419 let mut failures: u32 = 0;
1420 loop {
1421 let Some(this) = weak.upgrade() else {
1422 return;
1423 };
1424 let wait = match this.refresh_now().await {
1425 Ok(count) => {
1426 if first {
1427 info!(
1428 issuer = %redact_url(&this.config.issuer),
1429 keys = count,
1430 "OAuth: authorization server signing keys loaded"
1431 );
1432 } else {
1433 debug!(keys = count, "OAuth: signing keys refreshed");
1434 }
1435 failures = 0;
1436 JWKS_BACKGROUND_REFRESH_INTERVAL
1437 }
1438 Err(e) => {
1439 failures = failures.saturating_add(1);
1440 let wait = if this.keys.has_keys() {
1441 background_retry_delay(failures)
1442 } else {
1443 keyless_retry_delay(failures)
1444 };
1445 warn!(
1446 issuer = %redact_url(&this.config.issuer),
1447 error = %e,
1448 retry_in_secs = wait.as_secs(),
1449 "OAuth: could not load the authorization server's signing keys — \
1450 tokens signed by a key this server does not already hold will be \
1451 rejected (401) until a later attempt succeeds. Check {} / {} and \
1452 that this host can reach them.",
1453 this.config.key_naming.key("issuer"),
1454 this.config.key_naming.key("jwks_uri")
1455 );
1456 wait
1457 }
1458 };
1459 first = false;
1460 drop(this);
1464 if tokio::time::timeout(wait, alive.changed()).await.is_ok() {
1465 return;
1466 }
1467 }
1468 })
1469 }
1470}
1471
1472pub(crate) struct CheckedHeader {
1475 kid: Option<String>,
1476 alg: Algorithm,
1477}
1478
1479fn raw_header_fields(token: &str) -> (Option<String>, Option<String>) {
1487 let segment = token.split('.').next().unwrap_or_default();
1488 let Some(header) = URL_SAFE_NO_PAD
1489 .decode(segment)
1490 .ok()
1491 .and_then(|bytes| serde_json::from_slice::<Map<String, Value>>(&bytes).ok())
1492 else {
1493 return (None, None);
1494 };
1495 let field = |name: &str| header.get(name).and_then(Value::as_str).map(str::to_owned);
1496 (field("kid"), field("alg"))
1497}
1498
1499fn raw_header_has_member(token: &str, name: &str) -> bool {
1503 let segment = token.split('.').next().unwrap_or_default();
1504 URL_SAFE_NO_PAD
1505 .decode(segment)
1506 .ok()
1507 .and_then(|bytes| serde_json::from_slice::<Map<String, Value>>(&bytes).ok())
1508 .is_some_and(|header| header.contains_key(name))
1509}
1510
1511pub(crate) fn check_crit(token: &str) -> Result<(), TokenRejection> {
1519 let segment = token.split('.').next().unwrap_or_default();
1520 let header: Map<String, Value> = URL_SAFE_NO_PAD
1521 .decode(segment)
1522 .ok()
1523 .and_then(|raw| serde_json::from_slice(&raw).ok())
1524 .ok_or_else(|| {
1525 TokenRejection::invalid(
1526 InvalidTokenKind::MalformedHeader,
1527 "malformed token header: not a base64url JSON object",
1528 )
1529 })?;
1530 if header.contains_key("crit") {
1531 return Err(TokenRejection::invalid(
1532 InvalidTokenKind::CriticalHeader,
1533 "token header lists critical extensions (crit), none of which this server supports",
1534 ));
1535 }
1536 Ok(())
1537}
1538
1539fn decode_error_kind(kind: &jsonwebtoken::errors::ErrorKind, token: &str) -> InvalidTokenKind {
1555 use jsonwebtoken::errors::ErrorKind as E;
1556 let payload_claims = || {
1557 token
1558 .split('.')
1559 .nth(1)
1560 .and_then(|payload| URL_SAFE_NO_PAD.decode(payload).ok())
1561 .and_then(|raw| serde_json::from_slice::<Map<String, Value>>(&raw).ok())
1562 };
1563 if let E::MissingRequiredClaim(name) = kind {
1564 let present = payload_claims().is_some_and(|claims| claims.contains_key(name));
1565 return if present {
1566 InvalidTokenKind::MalformedClaim
1567 } else {
1568 InvalidTokenKind::MissingClaim
1569 };
1570 }
1571 if matches!(kind, E::Json(_)) && payload_claims().is_some() {
1572 return InvalidTokenKind::MalformedClaim;
1573 }
1574 match kind {
1575 E::InvalidSignature
1576 | E::InvalidEcdsaKey
1577 | E::InvalidRsaKey(_)
1578 | E::InvalidKeyFormat
1579 | E::Crypto(_) => InvalidTokenKind::BadSignature,
1580 E::ExpiredSignature => InvalidTokenKind::Expired,
1581 E::ImmatureSignature => InvalidTokenKind::NotYetValid,
1582 E::InvalidIssuer => InvalidTokenKind::WrongIssuer,
1583 E::InvalidAudience => InvalidTokenKind::WrongAudience,
1584 E::MissingRequiredClaim(_) => InvalidTokenKind::MissingClaim,
1585 E::InvalidToken | E::Base64(_) | E::Json(_) | E::Utf8(_) => {
1586 InvalidTokenKind::MalformedToken
1587 }
1588 E::InvalidAlgorithm | E::MissingAlgorithm | E::InvalidAlgorithmName => {
1589 InvalidTokenKind::AlgorithmNotAllowed
1590 }
1591 _ => InvalidTokenKind::Other,
1595 }
1596}
1597
1598fn claim_matches(actual: &Value, required: &Value) -> bool {
1603 actual == required
1604 || matches!(actual, Value::Array(items) if items.iter().any(|item| item == required))
1605}
1606
1607fn nbf_is_numeric_date(nbf: &Value) -> bool {
1610 nbf.as_u64().is_some()
1611 || nbf
1612 .as_f64()
1613 .is_some_and(|f| f.is_finite() && f >= 0.0 && f < u64::MAX as f64)
1614}
1615
1616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1619enum UnscopedPosture {
1620 Scoped,
1623 UnscopedButTypEnforced,
1626 IdTokensAccepted,
1629}
1630
1631fn unscoped_posture(config: &ResolvedOAuthConfig) -> UnscopedPosture {
1632 match (config.required_scopes.is_empty(), config.require_at_jwt) {
1633 (false, _) => UnscopedPosture::Scoped,
1634 (true, true) => UnscopedPosture::UnscopedButTypEnforced,
1635 (true, false) => UnscopedPosture::IdTokensAccepted,
1636 }
1637}
1638
1639pub(crate) fn non_canonical_warnings(config: &ResolvedOAuthConfig) -> Vec<String> {
1644 let naming = &config.key_naming;
1645 let urls = [
1646 ("issuer", Some(config.issuer.as_str())),
1647 ("resource", Some(config.resource.as_str())),
1648 ("jwks_uri", config.jwks_uri.as_deref()),
1649 ];
1650 urls.into_iter()
1651 .filter_map(|(name, value)| {
1652 let value = value?;
1653 let canonical = non_canonical_url(value)?;
1654 let consequence = match name {
1655 "issuer" => {
1656 "a token's `iss` is compared with it byte-for-byte, and no authorization \
1657 server writes its issuer this way, so every token will be refused; \
1658 discovery URLs are built from the raw text too"
1659 }
1660 "resource" => {
1661 "the `resource_metadata` URL in every challenge, the metadata document \
1662 and the route it is served on are built from the raw text, so clients \
1663 may not find the metadata, or may reject it"
1664 }
1665 _ => "logs and key-set status show the raw text, not the URL fetched",
1666 };
1667 Some(format!(
1668 "{} {:?} is not canonically spelled — it is read as {:?}; {consequence}. \
1669 Write it in that form.",
1670 naming.key(name),
1671 redact_url(value),
1672 redact_url(&canonical),
1673 ))
1674 })
1675 .collect()
1676}
1677
1678fn required_claim_footguns(config: &ResolvedOAuthConfig) -> Vec<(&str, &'static str)> {
1685 config
1686 .required_claims
1687 .keys()
1688 .filter_map(|name| {
1689 let why = if name == "scope"
1690 || name == "scp"
1691 || config.scope_claims.iter().any(|c| c == name)
1692 {
1693 "a scope claim is compared as one whole value (a space-delimited string \
1694 matches only when it holds exactly that value); require scopes with \
1695 required_scopes instead"
1696 } else if name == "azp" || name == "client_id" {
1697 "requiring one client claim bypasses allowed_client_ids' client_id-then-azp \
1698 precedence; restrict clients with allowed_client_ids instead"
1699 } else {
1700 return None;
1701 };
1702 Some((name.as_str(), why))
1703 })
1704 .collect()
1705}
1706
1707fn unadvertised_scopes(config: &ResolvedOAuthConfig) -> Vec<&str> {
1711 if config.scopes_supported.is_empty() {
1712 return Vec::new();
1713 }
1714 config
1715 .required_scopes
1716 .iter()
1717 .filter(|s| !config.scopes_supported.contains(s))
1718 .map(String::as_str)
1719 .collect()
1720}
1721
1722pub(crate) fn plain_http_non_loopback(url: &str) -> bool {
1730 reqwest::Url::parse(url.trim()).is_ok_and(|parsed| parsed_plain_http_non_loopback(&parsed))
1731}
1732
1733pub(crate) fn parsed_plain_http_non_loopback(url: &reqwest::Url) -> bool {
1736 url.scheme() == "http" && !is_loopback_url(url)
1737}
1738
1739pub(crate) fn non_canonical_url(url: &str) -> Option<String> {
1745 let value = url.trim();
1746 let parsed = reqwest::Url::parse(value).ok()?;
1747 (!is_canonical_spelling(value, &parsed)).then(|| parsed.to_string())
1748}
1749
1750pub(crate) fn is_canonical_url(url: &str) -> bool {
1753 let value = url.trim();
1754 reqwest::Url::parse(value).is_ok_and(|parsed| is_canonical_spelling(value, &parsed))
1755}
1756
1757fn is_canonical_spelling(value: &str, parsed: &reqwest::Url) -> bool {
1771 let scheme = parsed.scheme();
1772 value
1773 .get(..scheme.len())
1774 .filter(|s| s.eq_ignore_ascii_case(scheme))
1775 .and_then(|_| value[scheme.len()..].strip_prefix("://"))
1776 .is_some_and(|rest| {
1777 let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
1778 !authority.is_empty() && !authority.contains('\\')
1779 })
1780}
1781
1782pub(crate) fn url_is_loopback(url: &str) -> bool {
1787 reqwest::Url::parse(url.trim()).is_ok_and(|parsed| is_loopback_url(&parsed))
1788}
1789
1790pub(crate) fn is_loopback_url(parsed: &reqwest::Url) -> bool {
1792 let Some(host) = parsed.host_str() else {
1793 return false;
1794 };
1795 let host = host.trim_start_matches('[').trim_end_matches(']');
1796 host == "localhost"
1797 || host.ends_with(".localhost")
1798 || host
1799 .parse::<std::net::IpAddr>()
1800 .is_ok_and(|ip| ip.is_loopback())
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805 use super::*;
1806 use crate::config::KeyNamingBuf;
1807 use crate::jwks::{MAX_FETCH_BYTES, RefreshErrorKind};
1808 use crate::testing::*;
1809 use std::collections::HashMap;
1810 use std::sync::atomic::Ordering;
1811
1812 fn oauth_config(jwks_uri: &str) -> ResolvedOAuthConfig {
1813 resolved_config(jwks_uri)
1814 }
1815
1816 fn validator_no_cooldown(jwks_uri: &str) -> OAuthValidator {
1819 OAuthValidator::build(&oauth_config(jwks_uri), Duration::ZERO).unwrap()
1820 }
1821
1822 fn validator(jwks_uri: &str) -> OAuthValidator {
1823 OAuthValidator::new(&oauth_config(jwks_uri)).unwrap()
1824 }
1825
1826 fn validator_with(cfg: ResolvedOAuthConfig) -> OAuthValidator {
1827 OAuthValidator::new(&cfg).unwrap()
1828 }
1829
1830 fn claims(extra: serde_json::Value) -> serde_json::Value {
1831 let mut base = serde_json::json!({
1832 "iss": ISSUER, "aud": AUDIENCE, "sub": "user-1", "exp": now() + 3600,
1833 });
1834 for (k, v) in extra.as_object().unwrap() {
1835 base[k] = v.clone();
1836 }
1837 base
1838 }
1839
1840 fn is_invalid<T: std::fmt::Debug>(r: &Result<T, TokenRejection>) -> bool {
1841 matches!(r, Err(TokenRejection::Invalid(_)))
1842 }
1843
1844 #[test]
1847 fn construction_refuses_an_empty_audience_set_or_algorithm_list() {
1848 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1849 cfg.audience = String::new();
1850 let err = OAuthValidator::new(&cfg).unwrap_err();
1851 assert!(matches!(err, ValidatorError::NoAudience { .. }));
1852 assert_eq!(
1853 err.to_string(),
1854 "mcp.oauth: no accepted audience configured"
1855 );
1856
1857 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1859 cfg.audience = String::new();
1860 cfg.key_naming = KeyNamingBuf::Dotted(String::new());
1861 let err = OAuthValidator::new(&cfg).unwrap_err();
1862 assert_eq!(
1863 err.to_string(),
1864 "OAuth config: no accepted audience configured"
1865 );
1866
1867 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1868 cfg.algorithms.clear();
1869 let err = OAuthValidator::new(&cfg).unwrap_err();
1870 assert_eq!(err.to_string(), "mcp.oauth.algorithms is empty");
1871
1872 cfg.key_naming = KeyNamingBuf::Env("APP_OAUTH_".into());
1873 let err = OAuthValidator::new(&cfg).unwrap_err();
1874 assert_eq!(err.to_string(), "APP_OAUTH_ALGORITHMS is empty");
1875 }
1876
1877 #[test]
1878 fn construction_refuses_a_leeway_over_the_cap_set_after_resolving() {
1879 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1880 cfg.leeway_secs = crate::MAX_LEEWAY_SECS;
1881 OAuthValidator::new(&cfg).expect("the cap itself is allowed");
1882
1883 for leeway in [crate::MAX_LEEWAY_SECS + 1, 86_400, u64::MAX] {
1884 cfg.leeway_secs = leeway;
1885 let err = OAuthValidator::new(&cfg).unwrap_err();
1886 assert!(
1887 matches!(err, ValidatorError::LeewayTooLarge { .. }),
1888 "{err}"
1889 );
1890 assert_eq!(
1891 err.to_string(),
1892 format!("mcp.oauth.leeway_secs {leeway} is over the 300-second cap")
1893 );
1894 }
1895 }
1896
1897 #[test]
1898 fn the_unscoped_posture_is_classified_for_the_startup_log() {
1899 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
1900 assert!(!cfg.required_scopes.is_empty());
1901 assert_eq!(unscoped_posture(&cfg), UnscopedPosture::Scoped);
1902 cfg.require_at_jwt = true;
1903 assert_eq!(unscoped_posture(&cfg), UnscopedPosture::Scoped);
1904
1905 cfg.required_scopes.clear();
1907 assert_eq!(
1908 unscoped_posture(&cfg),
1909 UnscopedPosture::UnscopedButTypEnforced
1910 );
1911 cfg.require_at_jwt = false;
1912 assert_eq!(unscoped_posture(&cfg), UnscopedPosture::IdTokensAccepted);
1913 }
1914
1915 #[tokio::test]
1919 async fn an_id_token_is_accepted_only_when_unscoped_and_typ_is_not_enforced() {
1920 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
1921 let id_token = mint_with(
1922 Algorithm::RS256,
1923 Some(KID_A),
1924 Some("JWT"),
1925 &claims(serde_json::json!({ "nonce": "n-1", "auth_time": now() })),
1926 );
1927
1928 let mut cfg = oauth_config(&jwks.url);
1929 cfg.required_scopes.clear();
1930 assert!(
1931 validator_with(cfg.clone())
1932 .validate(&id_token)
1933 .await
1934 .is_ok()
1935 );
1936
1937 let mut scoped = cfg.clone();
1938 scoped.required_scopes = vec!["mcp:read".into()];
1939 assert_eq!(
1940 validator_with(scoped).validate(&id_token).await,
1941 Err(TokenRejection::InsufficientScope)
1942 );
1943
1944 cfg.require_at_jwt = true;
1945 assert!(is_invalid(&validator_with(cfg).validate(&id_token).await));
1946 }
1947
1948 #[test]
1949 fn plain_http_detection_exempts_loopback_only() {
1950 assert!(plain_http_non_loopback("http://idp.example.com/jwks"));
1951 assert!(plain_http_non_loopback("HTTP://idp.example.com/jwks"));
1952 assert!(!plain_http_non_loopback("https://idp.example.com/jwks"));
1953 assert!(!plain_http_non_loopback("http://127.0.0.1:9000/jwks"));
1954 assert!(!plain_http_non_loopback("http://localhost/jwks"));
1955 assert!(!plain_http_non_loopback("http://[::1]:9000/jwks"));
1956 }
1957
1958 #[test]
1959 fn plain_http_detection_decides_on_the_parsed_url_not_the_raw_prefix() {
1960 for spelling in [
1962 "http:/idp.example.com/jwks",
1963 "http:idp.example.com/jwks",
1964 "HTTP:\\\\idp.example.com\\jwks",
1965 " http://idp.example.com/jwks",
1966 "http:///idp.example.com/jwks",
1967 "\thttp://idp.example.com/jwks",
1968 ] {
1969 assert!(plain_http_non_loopback(spelling), "{spelling:?}");
1970 }
1971 for spelling in [
1973 "http:/localhost/jwks",
1974 "http:127.0.0.1:9000/jwks",
1975 "HTTP:\\\\[::1]\\jwks",
1976 " http://localhost/jwks",
1977 "https:/idp.example.com/jwks",
1978 "https:idp.example.com/jwks",
1979 "not a url",
1980 ] {
1981 assert!(!plain_http_non_loopback(spelling), "{spelling:?}");
1982 }
1983 }
1984
1985 #[test]
1986 fn accessors_expose_the_resource_and_where_its_metadata_lives() {
1987 let v = validator("http://127.0.0.1:1/jwks");
1988 assert_eq!(v.resource(), RESOURCE);
1989 assert_eq!(
1990 v.resource_metadata_url(),
1991 "https://kb.example.test/.well-known/oauth-protected-resource/mcp"
1992 );
1993 assert_eq!(
1994 v.metadata_path(),
1995 "/.well-known/oauth-protected-resource/mcp"
1996 );
1997 assert_eq!(v.config().issuer, ISSUER);
1998 }
1999
2000 #[test]
2001 fn a_hand_edited_unparseable_or_non_canonical_url_never_reaches_another_host() {
2002 let host = |url: &str| {
2006 reqwest::Url::parse(url)
2007 .ok()
2008 .and_then(|u| u.host_str().map(str::to_owned))
2009 };
2010 for (value, expected_host) in [
2011 ("https://", None),
2012 ("https:///", None),
2013 ("https:", None),
2014 ("https:///idp.example.test/app/", Some("idp.example.test")),
2015 ] {
2016 let mut cfg = resolved_config("http://127.0.0.1:1/jwks");
2017 cfg.issuer = value.into();
2018 cfg.resource = value.into();
2019 let v = OAuthValidator::new(&cfg).unwrap();
2020 assert_eq!(
2021 host(v.resource_metadata_url()).as_deref(),
2022 expected_host,
2023 "{value:?}: {}",
2024 v.resource_metadata_url()
2025 );
2026 assert!(v.metadata_path().starts_with('/'), "{value:?}");
2027 for url in crate::jwks::discovery_urls(&cfg.issuer) {
2028 assert_eq!(host(&url).as_deref(), expected_host, "{value:?}: {url}");
2029 }
2030 }
2031 assert!(!is_canonical_url("https://"));
2032 assert!(!is_canonical_url("https:///idp.example.test/"));
2033 assert!(non_canonical_url("https://").is_none());
2034 }
2035
2036 #[test]
2037 fn a_backslash_only_in_the_path_is_canonical_for_splitting() {
2038 let issuer = "https://idp.example.test/app\\tenant/";
2041 assert!(is_canonical_url(issuer));
2042 assert_eq!(
2043 crate::jwks::discovery_urls(issuer),
2044 [
2045 "https://idp.example.test/app\\tenant/.well-known/openid-configuration",
2046 "https://idp.example.test/.well-known/oauth-authorization-server/app\\tenant",
2047 ]
2048 );
2049 assert_eq!(
2050 crate::challenge::resource_metadata_url("https://api.example.test/v1\\x"),
2051 "https://api.example.test/.well-known/oauth-protected-resource/v1\\x"
2052 );
2053 assert!(!is_canonical_url("https://idp.example.test\\app"));
2055 assert_eq!(
2056 crate::jwks::discovery_urls("https://idp.example.test\\app").len(),
2057 1
2058 );
2059 }
2060
2061 #[test]
2064 fn metadata_document_has_the_rfc_9728_shape() {
2065 let v = validator("http://127.0.0.1:1/jwks");
2066 let doc = v.metadata();
2067 assert_eq!(doc["resource"], RESOURCE);
2068 assert_eq!(doc["authorization_servers"][0], ISSUER);
2071 assert_eq!(doc["scopes_supported"][0], "mcp:read");
2072 assert_eq!(doc["scopes_supported"][1], "mcp:write");
2073 assert_eq!(doc["bearer_methods_supported"][0], "header");
2074 assert!(doc.get("resource_name").is_none());
2077
2078 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2079 cfg.resource_name = Some("mcp-md-wiki knowledge base (MCP)".into());
2080 let v = validator_with(cfg);
2081 let doc = v.metadata();
2082 assert_eq!(doc["resource_name"], "mcp-md-wiki knowledge base (MCP)");
2083 assert_eq!(
2084 doc.to_string(),
2085 "{\"authorization_servers\":[\"https://authentik.example.test/application/o/example-app/\"],\
2086 \"bearer_methods_supported\":[\"header\"],\
2087 \"resource\":\"https://kb.example.test/mcp\",\
2088 \"resource_name\":\"mcp-md-wiki knowledge base (MCP)\",\
2089 \"scopes_supported\":[\"mcp:read\",\"mcp:write\"]}"
2090 );
2091 }
2092
2093 #[test]
2094 fn invalid_token_challenge_is_well_formed() {
2095 let v = validator("http://127.0.0.1:1/jwks");
2096 assert_eq!(
2097 v.invalid_token_challenge(),
2098 "Bearer error=\"invalid_token\", \
2099 resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
2100 scope=\"mcp:read mcp:write\""
2101 );
2102 }
2103
2104 #[test]
2105 fn invalid_token_challenge_names_the_required_scopes_when_none_is_advertised() {
2106 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2109 cfg.scopes_supported = Vec::new();
2110 let v = validator_with(cfg.clone());
2111 assert_eq!(
2112 v.invalid_token_challenge(),
2113 "Bearer error=\"invalid_token\", \
2114 resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\", \
2115 scope=\"mcp:read\""
2116 );
2117 assert!(
2119 v.metadata().get("scopes_supported").is_none(),
2120 "{}",
2121 v.metadata()
2122 );
2123
2124 cfg.required_scopes.clear();
2127 assert_eq!(
2128 validator_with(cfg).invalid_token_challenge(),
2129 "Bearer error=\"invalid_token\", \
2130 resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
2131 );
2132 }
2133
2134 #[test]
2135 fn insufficient_scope_challenge_names_the_missing_scope_not_the_menu() {
2136 let v = validator("http://127.0.0.1:1/jwks");
2139 assert_eq!(
2140 v.insufficient_scope_challenge(),
2141 "Bearer error=\"insufficient_scope\", scope=\"mcp:read\", \
2142 resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
2143 );
2144 }
2145
2146 #[test]
2147 fn insufficient_scope_challenge_lists_every_required_scope_space_delimited() {
2148 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2149 cfg.required_scopes = vec!["mcp:read".into(), "mcp:write".into()];
2150 assert_eq!(
2151 validator_with(cfg).insufficient_scope_challenge(),
2152 "Bearer error=\"insufficient_scope\", scope=\"mcp:read mcp:write\", \
2153 resource_metadata=\"https://kb.example.test/.well-known/oauth-protected-resource/mcp\""
2154 );
2155 }
2156
2157 const METADATA: &str = "https://kb.example.test/.well-known/oauth-protected-resource/mcp";
2158
2159 #[test]
2160 fn the_per_request_challenge_with_the_configured_scopes_is_the_fixed_one() {
2161 for required in [vec![], vec!["mcp:read"], vec!["mcp:read", "mcp:write"]] {
2162 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2163 cfg.required_scopes = required.iter().map(|s| s.to_string()).collect();
2164 let v = validator_with(cfg);
2165 assert_eq!(
2166 v.insufficient_scope_challenge_for(&required, None),
2167 v.insufficient_scope_challenge(),
2168 "{required:?}"
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn the_per_request_challenge_names_the_given_scopes_and_description() {
2175 let v = validator("http://127.0.0.1:1/jwks");
2176 assert_eq!(
2177 v.insufficient_scope_challenge_for(&["mcp:read", "mcp:write", "mcp:read"], None),
2178 format!(
2179 "Bearer error=\"insufficient_scope\", scope=\"mcp:read mcp:write\", \
2180 resource_metadata=\"{METADATA}\""
2181 )
2182 );
2183 assert_eq!(
2184 v.insufficient_scope_challenge_for(&["files:write"], Some(" Write access needed ")),
2185 format!(
2186 "Bearer error=\"insufficient_scope\", scope=\"files:write\", \
2187 resource_metadata=\"{METADATA}\", error_description=\"Write access needed\""
2188 )
2189 );
2190 assert_eq!(
2192 v.insufficient_scope_challenge_for(&[], Some(" \r\n ")),
2193 format!("Bearer error=\"insufficient_scope\", resource_metadata=\"{METADATA}\"")
2194 );
2195 }
2196
2197 #[test]
2198 fn the_per_request_challenge_cannot_be_injected_into() {
2199 let v = validator("http://127.0.0.1:1/jwks");
2200 let attempts = [
2201 "x\", scope=\"admin",
2202 "x\r\nSet-Cookie: session=1",
2203 "x\\\", error=\"invalid_token",
2204 "caf\u{e9} \u{0}\u{7f}\u{2028}",
2205 ];
2206 for description in attempts {
2207 let challenge = v.insufficient_scope_challenge_for(&["mcp:write"], Some(description));
2208 assert!(challenge::is_header_value(&challenge), "{challenge:?}");
2209 let (head, described) = challenge
2210 .split_once(", error_description=\"")
2211 .expect("a description is kept");
2212 assert_eq!(described.matches('"').count(), 1, "{challenge:?}");
2215 assert!(described.ends_with('"'));
2216 assert!(!described.contains('\\'));
2217 assert_eq!(
2218 head,
2219 format!(
2220 "Bearer error=\"insufficient_scope\", scope=\"mcp:write\", \
2221 resource_metadata=\"{METADATA}\""
2222 )
2223 );
2224 }
2225 assert!(
2226 v.insufficient_scope_challenge_for(&["mcp:write"], Some("x\", scope=\"admin"))
2227 .ends_with("error_description=\"x , scope= admin\"")
2228 );
2229 assert_eq!(
2231 v.insufficient_scope_challenge_for(
2232 &[
2233 "ok",
2234 "",
2235 "two words",
2236 "q\"uote",
2237 "back\\slash",
2238 "nl\n",
2239 "caf\u{e9}"
2240 ],
2241 None
2242 ),
2243 format!(
2244 "Bearer error=\"insufficient_scope\", scope=\"ok\", resource_metadata=\"{METADATA}\""
2245 )
2246 );
2247 let long = "a".repeat(1000);
2249 let challenge = v.insufficient_scope_challenge_for(&[], Some(&long));
2250 assert!(challenge.ends_with(&format!(
2251 "error_description=\"{}\"",
2252 "a".repeat(challenge::MAX_ERROR_DESCRIPTION_BYTES)
2253 )));
2254 }
2255
2256 #[test]
2257 fn a_fallback_validator_leaves_resource_metadata_out_of_the_per_request_challenge() {
2258 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
2259 cfg.resource = "https://api.example.test/v1\r\nX-Injected: 1".into();
2260 let v = validator_with(cfg);
2261 assert!(v.challenge_fell_back());
2262 assert_eq!(
2263 v.insufficient_scope_challenge_for(&["mcp:write"], Some("d")),
2264 "Bearer error=\"insufficient_scope\", scope=\"mcp:write\", error_description=\"d\""
2265 );
2266 }
2267
2268 #[test]
2269 fn the_floor_comes_first_and_is_deduplicated() {
2270 let v = validator("http://127.0.0.1:1/jwks");
2271 assert_eq!(
2272 v.scopes_with_floor(&["mcp:write", "mcp:read", "mcp:write"]),
2273 ["mcp:read", "mcp:write"]
2274 );
2275 }
2276
2277 #[tokio::test]
2280 async fn a_well_formed_token_is_accepted_and_yields_its_scopes() {
2281 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2282 let v = validator(&jwks.url);
2283 let token = v.validate(&valid_token()).await.unwrap();
2284 assert_eq!(token.subject.as_deref(), Some("user-1"));
2285 assert_eq!(token.scopes, vec!["mcp:read", "mcp:write"]);
2286 assert!(token.has_scope("mcp:write"));
2287 }
2288
2289 #[tokio::test]
2290 async fn an_empty_credential_is_missing_not_invalid() {
2291 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2292 let v = validator(&jwks.url);
2293 assert_eq!(v.validate("").await.unwrap_err(), TokenRejection::Missing);
2294 assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2295 }
2296
2297 #[tokio::test]
2298 async fn a_wrong_issuer_is_rejected() {
2299 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2300 let v = validator(&jwks.url);
2301 let token = mint(
2304 KEY_A_PEM,
2305 KID_A,
2306 &claims(serde_json::json!({
2307 "iss": ISSUER.trim_end_matches('/'), "scope": "mcp:read",
2308 })),
2309 );
2310 assert!(is_invalid(&v.validate(&token).await));
2311 }
2312
2313 #[tokio::test]
2314 async fn an_issuer_array_containing_the_right_issuer_is_rejected() {
2315 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2317 let v = validator(&jwks.url);
2318 let token = mint(
2319 KEY_A_PEM,
2320 KID_A,
2321 &claims(serde_json::json!({
2322 "iss": ["https://evil.example.test/", ISSUER], "scope": "mcp:read",
2323 })),
2324 );
2325 assert!(is_invalid(&v.validate(&token).await));
2326 }
2327
2328 #[tokio::test]
2329 async fn a_missing_issuer_or_audience_is_rejected() {
2330 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2331 let v = validator(&jwks.url);
2332 for claims in [
2336 serde_json::json!({"aud": AUDIENCE, "exp": now() + 3600, "scope": "mcp:read"}),
2337 serde_json::json!({"iss": ISSUER, "exp": now() + 3600, "scope": "mcp:read"}),
2338 ] {
2339 let token = mint(KEY_A_PEM, KID_A, &claims);
2340 assert!(is_invalid(&v.validate(&token).await));
2341 }
2342 }
2343
2344 #[tokio::test]
2347 async fn aud_is_accepted_as_a_string_and_as_an_array() {
2348 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2349 let v = validator(&jwks.url);
2350 for aud in [
2351 serde_json::json!(AUDIENCE),
2352 serde_json::json!(["some-other-client", AUDIENCE]),
2353 ] {
2354 let token = mint(
2355 KEY_A_PEM,
2356 KID_A,
2357 &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
2358 );
2359 assert!(
2360 v.validate(&token).await.is_ok(),
2361 "aud must be accepted in both RFC 7519 §4.1.3 shapes"
2362 );
2363 }
2364 }
2365
2366 #[tokio::test]
2367 async fn a_wrong_empty_or_malformed_audience_is_rejected() {
2368 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2369 let v = validator(&jwks.url);
2370 for aud in [
2371 serde_json::json!("some-other-client"),
2372 serde_json::json!([]),
2373 serde_json::json!(["some-other-client"]),
2374 serde_json::json!(42),
2375 serde_json::json!([AUDIENCE, 42]),
2376 serde_json::json!(""),
2377 ] {
2378 let token = mint(
2379 KEY_A_PEM,
2380 KID_A,
2381 &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
2382 );
2383 assert!(
2384 is_invalid(&v.validate(&token).await),
2385 "aud {aud} must never be accepted"
2386 );
2387 }
2388 }
2389
2390 #[tokio::test]
2391 async fn every_configured_audience_is_accepted_and_nothing_else() {
2392 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2395 let mut cfg = oauth_config(&jwks.url);
2396 cfg.audiences = vec![RESOURCE.to_string()];
2397 let v = validator_with(cfg);
2398 for aud in [AUDIENCE, RESOURCE] {
2399 let token = mint(
2400 KEY_A_PEM,
2401 KID_A,
2402 &claims(serde_json::json!({"aud": aud, "scope": "mcp:read"})),
2403 );
2404 assert!(v.validate(&token).await.is_ok(), "{aud} is configured");
2405 }
2406 let token = mint(
2407 KEY_A_PEM,
2408 KID_A,
2409 &claims(
2410 serde_json::json!({"aud": "https://other.example.test/mcp", "scope": "mcp:read"}),
2411 ),
2412 );
2413 assert!(is_invalid(&v.validate(&token).await));
2414 }
2415
2416 #[tokio::test]
2419 async fn an_expired_token_is_rejected_beyond_the_leeway() {
2420 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2421 let v = validator(&jwks.url);
2422 let token = mint(
2423 KEY_A_PEM,
2424 KID_A,
2425 &claims(serde_json::json!({
2426 "exp": now() - (crate::DEFAULT_LEEWAY_SECS + 60), "scope": "mcp:read",
2427 })),
2428 );
2429 assert!(is_invalid(&v.validate(&token).await));
2430 }
2431
2432 #[tokio::test]
2433 async fn skew_within_the_leeway_is_tolerated_and_zero_leeway_is_strict() {
2434 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2435 let just_expired = mint(
2436 KEY_A_PEM,
2437 KID_A,
2438 &claims(serde_json::json!({"exp": now() - 10, "scope": "mcp:read"})),
2439 );
2440 let not_yet_valid = mint(
2441 KEY_A_PEM,
2442 KID_A,
2443 &claims(serde_json::json!({"nbf": now() + 10, "scope": "mcp:read"})),
2444 );
2445
2446 let lenient = validator(&jwks.url);
2447 assert!(lenient.validate(&just_expired).await.is_ok());
2448 assert!(lenient.validate(¬_yet_valid).await.is_ok());
2449
2450 let mut cfg = oauth_config(&jwks.url);
2451 cfg.leeway_secs = 0;
2452 let strict = validator_with(cfg);
2453 assert!(is_invalid(&strict.validate(&just_expired).await));
2454 assert!(is_invalid(&strict.validate(¬_yet_valid).await));
2455 }
2456
2457 #[tokio::test]
2458 async fn a_token_used_before_nbf_is_rejected_beyond_the_leeway() {
2459 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2460 let v = validator(&jwks.url);
2461 let token = mint(
2462 KEY_A_PEM,
2463 KID_A,
2464 &claims(serde_json::json!({
2465 "nbf": now() + crate::DEFAULT_LEEWAY_SECS + 120, "scope": "mcp:read",
2466 })),
2467 );
2468 assert!(is_invalid(&v.validate(&token).await));
2469 }
2470
2471 #[tokio::test]
2472 async fn a_token_signed_by_the_wrong_key_is_rejected() {
2473 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2474 let v = validator(&jwks.url);
2475 let token = mint(
2478 KEY_B_PEM,
2479 KID_A,
2480 &claims(serde_json::json!({"scope": "mcp:read"})),
2481 );
2482 assert!(is_invalid(&v.validate(&token).await));
2483 }
2484
2485 async fn scopes_of(extra: serde_json::Value) -> Result<AuthorizedToken, TokenRejection> {
2488 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2489 let v = validator(&jwks.url);
2490 v.validate(&mint(KEY_A_PEM, KID_A, &claims(extra))).await
2491 }
2492
2493 #[tokio::test]
2494 async fn scope_as_a_space_delimited_string_is_read() {
2495 let t = scopes_of(serde_json::json!({"scope": "openid mcp:read\tmcp:write"}))
2496 .await
2497 .unwrap();
2498 assert_eq!(t.scopes, ["openid", "mcp:read", "mcp:write"]);
2499 }
2500
2501 #[tokio::test]
2502 async fn scp_as_an_array_is_read() {
2503 let t = scopes_of(serde_json::json!({"scp": ["mcp:read", "mcp:write"]}))
2505 .await
2506 .unwrap();
2507 assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
2508 }
2509
2510 #[tokio::test]
2511 async fn scp_as_a_space_delimited_string_is_read() {
2512 let t = scopes_of(serde_json::json!({"scp": "mcp:read mcp:write"}))
2514 .await
2515 .unwrap();
2516 assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
2517 }
2518
2519 #[tokio::test]
2520 async fn scope_and_scp_together_are_unioned_without_duplicates() {
2521 let t = scopes_of(serde_json::json!({
2522 "scope": "openid mcp:read", "scp": ["mcp:read", "mcp:write"],
2523 }))
2524 .await
2525 .unwrap();
2526 assert_eq!(t.scopes, ["openid", "mcp:read", "mcp:write"]);
2527 }
2528
2529 #[tokio::test]
2530 async fn the_required_scope_in_scp_alone_satisfies_the_check() {
2531 let t = scopes_of(serde_json::json!({"scope": "openid", "scp": ["mcp:read"]}))
2532 .await
2533 .unwrap();
2534 assert!(t.has_scope("mcp:read"));
2535 }
2536
2537 #[tokio::test]
2538 async fn neither_claim_or_non_string_shapes_are_insufficient_not_invalid() {
2539 for extra in [
2540 serde_json::json!({}),
2541 serde_json::json!({"scope": ""}),
2542 serde_json::json!({"scope": "openid profile"}),
2543 serde_json::json!({"scp": []}),
2544 serde_json::json!({"scp": [1, {"mcp:read": true}]}),
2545 serde_json::json!({"scope": {"mcp:read": true}}),
2546 serde_json::json!({"scope": "MCP:READ mcp:read:extra"}),
2548 ] {
2549 assert_eq!(
2550 scopes_of(extra.clone()).await.unwrap_err(),
2551 TokenRejection::InsufficientScope,
2552 "{extra} — the token itself is fine; conflating this with \
2553 invalid_token sends the client round the authorization flow to the \
2554 same refusal"
2555 );
2556 }
2557 }
2558
2559 #[tokio::test]
2560 async fn only_the_configured_scope_claims_are_read() {
2561 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2562 let mut cfg = oauth_config(&jwks.url);
2563 cfg.scope_claims = vec!["scope".to_string()];
2564 let v = validator_with(cfg);
2565 let token = mint(
2566 KEY_A_PEM,
2567 KID_A,
2568 &claims(serde_json::json!({"scp": ["mcp:read"]})),
2569 );
2570 assert_eq!(
2571 v.validate(&token).await.unwrap_err(),
2572 TokenRejection::InsufficientScope
2573 );
2574 }
2575
2576 #[tokio::test]
2579 async fn every_required_scope_must_be_present() {
2580 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2581 let mut cfg = oauth_config(&jwks.url);
2582 cfg.required_scopes = vec!["mcp:read".into(), "mcp:write".into()];
2583 let v = validator_with(cfg);
2584 for (scope, ok) in [
2585 ("mcp:read", false),
2586 ("mcp:write", false),
2587 ("openid", false),
2588 ("mcp:read mcp:write", true),
2589 ("mcp:write openid mcp:read", true),
2590 ] {
2591 let token = mint(
2592 KEY_A_PEM,
2593 KID_A,
2594 &claims(serde_json::json!({ "scope": scope })),
2595 );
2596 let result = v.validate(&token).await;
2597 if ok {
2598 assert!(result.is_ok(), "{scope:?} carries every required scope");
2599 } else {
2600 assert_eq!(
2601 result.unwrap_err(),
2602 TokenRejection::InsufficientScope,
2603 "{scope:?} lacks one"
2604 );
2605 }
2606 }
2607 }
2608
2609 #[tokio::test]
2610 async fn an_empty_required_scope_set_passes_the_scope_check() {
2611 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2612 let mut cfg = oauth_config(&jwks.url);
2613 cfg.required_scopes.clear();
2614 let v = validator_with(cfg);
2615 let t = v
2617 .validate(&mint(KEY_A_PEM, KID_A, &claims(serde_json::json!({}))))
2618 .await
2619 .unwrap();
2620 assert!(t.scopes.is_empty());
2621 let expired = mint(
2623 KEY_A_PEM,
2624 KID_A,
2625 &claims(serde_json::json!({"exp": now() - 3600})),
2626 );
2627 assert!(is_invalid(&v.validate(&expired).await));
2628 }
2629
2630 #[tokio::test]
2633 async fn the_principal_is_the_first_present_claim_of_the_chain() {
2634 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2635 let mut cfg = oauth_config(&jwks.url);
2636 cfg.principal_claims = vec!["preferred_username".into(), "email".into(), "sub".into()];
2637 let v = validator_with(cfg);
2638 for (extra, expected) in [
2639 (
2640 serde_json::json!({"preferred_username": "alice", "email": "a@example.com"}),
2641 "alice",
2642 ),
2643 (
2644 serde_json::json!({"preferred_username": "", "email": "a@example.com"}),
2645 "a@example.com",
2646 ),
2647 (serde_json::json!({"preferred_username": 7}), "user-1"),
2648 ] {
2649 let mut c = claims(extra);
2650 c["scope"] = "mcp:read".into();
2651 let t = v.validate(&mint(KEY_A_PEM, KID_A, &c)).await.unwrap();
2652 assert_eq!(t.principal.as_deref(), Some(expected));
2653 }
2654 }
2655
2656 #[tokio::test]
2660 async fn long_subjects_and_principals_are_kept_verbatim() {
2661 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2662 let mut cfg = oauth_config(&jwks.url);
2663 cfg.principal_claims = vec!["email".into()];
2664 let v = validator_with(cfg);
2665 let prefix = "u".repeat(200);
2666 let mut seen = Vec::new();
2667 for suffix in ["-a", "-b"] {
2668 let sub = format!("{prefix}{suffix}");
2669 let email = format!("{prefix}{suffix}@example.com");
2670 let c = claims(serde_json::json!({"sub": sub, "email": email, "scope": "mcp:read"}));
2671 let t = v.validate(&mint(KEY_A_PEM, KID_A, &c)).await.unwrap();
2672 assert_eq!(t.subject.as_deref(), Some(sub.as_str()));
2673 assert_eq!(t.principal.as_deref(), Some(email.as_str()));
2674 seen.push(t.subject);
2675 }
2676 assert_ne!(seen[0], seen[1]);
2677 }
2678
2679 #[tokio::test]
2682 async fn alg_none_is_rejected_before_any_jwks_fetch() {
2683 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2684 let v = validator(&jwks.url);
2685 let payload = "eyJpc3MiOiJ4IiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJzY29wZSI6Im1jcDpyZWFkIiwiZXhwIjo5OTk5OTk5OTk5fQ";
2689 for header in ["eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0", "eyJhbGciOiJOb25lIn0"] {
2690 let token = format!("{header}.{payload}.");
2691 assert!(is_invalid(&v.validate(&token).await), "{header}");
2692 }
2693 assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2694 }
2695
2696 #[tokio::test]
2697 async fn hs256_signed_with_the_public_key_is_rejected_before_any_jwks_fetch() {
2698 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2701 let v = validator(&jwks.url);
2702 let published = jwks_body();
2703 for secret in [N_A.as_bytes(), published.as_bytes()] {
2704 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
2705 header.kid = Some(KID_A.to_string());
2706 let token = jsonwebtoken::encode(
2707 &header,
2708 &claims(serde_json::json!({"scope": "mcp:read"})),
2709 &jsonwebtoken::EncodingKey::from_secret(secret),
2710 )
2711 .unwrap();
2712 assert!(is_invalid(&v.validate(&token).await));
2713 }
2714 assert_eq!(
2715 jwks.hits.load(Ordering::SeqCst),
2716 0,
2717 "a junk algorithm must not be able to schedule IdP traffic"
2718 );
2719 }
2720
2721 #[tokio::test]
2722 async fn a_symmetric_key_in_the_jwks_is_never_used() {
2723 let body = jwks_of(&[serde_json::json!({"kty": "oct", "kid": KID_A, "k": "c2VjcmV0"})]);
2727 let jwks = spawn_jwks_server("200 OK", body).await;
2728 let v = validator(&jwks.url);
2729 assert!(is_invalid(&v.validate(&valid_token()).await));
2730 }
2731
2732 #[tokio::test]
2733 async fn a_token_alg_the_named_key_cannot_produce_is_rejected() {
2734 let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2737 let v = validator(&jwks.url);
2738 let c = claims(serde_json::json!({"scope": "mcp:read"}));
2739 let es_labelled_rsa = mint_with(Algorithm::ES256, Some(KID_A), None, &c.clone());
2740 assert!(is_invalid(&v.validate(&es_labelled_rsa).await));
2741 let rs_labelled_ec = mint_with(Algorithm::RS256, Some(KID_EC), None, &c.clone());
2742 assert!(is_invalid(&v.validate(&rs_labelled_ec).await));
2743 let ps_on_rs_only_key = mint_with(Algorithm::PS256, Some(KID_A), None, &c);
2746 assert!(is_invalid(&v.validate(&ps_on_rs_only_key).await));
2747 }
2748
2749 #[tokio::test]
2750 async fn es256_ps256_and_eddsa_tokens_are_accepted() {
2751 let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2752 let v = validator(&jwks.url);
2753 let c = claims(serde_json::json!({"scope": "mcp:read"}));
2754 for (alg, kid) in [
2755 (Algorithm::ES256, KID_EC),
2756 (Algorithm::PS256, "test-key-a-pss"),
2757 (Algorithm::RS384, "test-key-a-pss"),
2758 (Algorithm::EdDSA, KID_ED),
2759 (Algorithm::RS256, KID_A),
2760 ] {
2761 let token = mint_with(alg, Some(kid), Some("at+jwt"), &c.clone());
2762 assert!(v.validate(&token).await.is_ok(), "{alg:?} must verify");
2763 }
2764 }
2765
2766 #[tokio::test]
2767 async fn an_algorithm_outside_the_allowlist_is_rejected_before_any_jwks_fetch() {
2768 let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2769 let mut cfg = oauth_config(&jwks.url);
2770 cfg.algorithms = vec![Algorithm::RS256];
2771 let v = validator_with(cfg);
2772 let token = mint_with(
2773 Algorithm::ES256,
2774 Some(KID_EC),
2775 None,
2776 &claims(serde_json::json!({"scope": "mcp:read"})),
2777 );
2778 assert!(is_invalid(&v.validate(&token).await));
2779 assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2780 }
2781
2782 #[tokio::test]
2783 async fn rejection_reasons_name_settings_per_key_naming() {
2784 let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
2785 let token = mint_with(
2786 Algorithm::ES256,
2787 Some(KID_EC),
2788 None,
2789 &claims(serde_json::json!({"scope": "mcp:read"})),
2790 );
2791 for (naming, expected) in [
2792 (
2793 KeyNamingBuf::Dotted("mcp.oauth".into()),
2794 "token algorithm ES256 is not in mcp.oauth.algorithms",
2795 ),
2796 (
2797 KeyNamingBuf::Env("APP_OAUTH_".into()),
2798 "token algorithm ES256 is not in APP_OAUTH_ALGORITHMS",
2799 ),
2800 ] {
2801 let mut cfg = oauth_config(&jwks.url);
2802 cfg.algorithms = vec![Algorithm::RS256];
2803 cfg.key_naming = naming;
2804 crate::token::assert_invalid(
2805 Err::<(), _>(validator_with(cfg).validate(&token).await.unwrap_err()),
2806 InvalidTokenKind::AlgorithmNotAllowed,
2807 expected,
2808 "",
2809 );
2810 }
2811 }
2812
2813 #[tokio::test]
2816 async fn typ_access_token_types_pass_and_other_jwt_types_fail() {
2817 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2818 let v = validator(&jwks.url);
2819 let c = claims(serde_json::json!({"scope": "mcp:read"}));
2820 for typ in [
2821 None,
2822 Some("JWT"),
2823 Some("jwt"),
2824 Some("at+jwt"),
2825 Some("AT+JWT"),
2826 Some("application/at+jwt"),
2827 ] {
2828 let token = mint_with(Algorithm::RS256, Some(KID_A), typ, &c.clone());
2829 assert!(v.validate(&token).await.is_ok(), "typ {typ:?} must pass");
2830 }
2831 for typ in ["dpop+jwt", "logout+jwt", "secevent+jwt", "JOSE"] {
2832 let token = mint_with(Algorithm::RS256, Some(KID_A), Some(typ), &c.clone());
2833 assert!(is_invalid(&v.validate(&token).await), "typ {typ} must fail");
2834 }
2835 }
2836
2837 #[tokio::test]
2838 async fn require_at_jwt_refuses_plain_jwt_and_a_missing_typ() {
2839 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2840 let mut cfg = oauth_config(&jwks.url);
2841 cfg.require_at_jwt = true;
2842 let v = validator_with(cfg);
2843 let c = claims(serde_json::json!({"scope": "mcp:read"}));
2844 for typ in [None, Some("JWT")] {
2845 let token = mint_with(Algorithm::RS256, Some(KID_A), typ, &c.clone());
2846 assert!(is_invalid(&v.validate(&token).await), "typ {typ:?}");
2847 }
2848 let token = mint_with(Algorithm::RS256, Some(KID_A), Some("at+jwt"), &c);
2849 assert!(v.validate(&token).await.is_ok());
2850 }
2851
2852 #[tokio::test]
2855 async fn garbage_opaque_and_oversized_credentials_are_rejected_without_a_fetch() {
2856 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2857 let v = validator(&jwks.url);
2858 let oversized = format!("{}.{}.{}", "a".repeat(MAX_TOKEN_BYTES), "b", "c");
2859 for junk in [
2860 "not-a-jwt",
2861 "a.b.c",
2862 "a.b",
2863 "authelia_at_Xy9vQ3c2bG9uZ3JhbmRvbXN0cmluZw.abc",
2865 oversized.as_str(),
2866 ] {
2867 assert!(is_invalid(&v.validate(junk).await), "{junk:.40}");
2868 }
2869 assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2870 }
2871
2872 fn b64url(bytes: &[u8]) -> String {
2874 const ALPHABET: &[u8; 64] =
2875 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
2876 let mut out = String::new();
2877 for chunk in bytes.chunks(3) {
2878 let n = chunk
2879 .iter()
2880 .enumerate()
2881 .fold(0u32, |acc, (i, &b)| acc | (u32::from(b) << (16 - 8 * i)));
2882 for i in 0..=chunk.len() {
2883 out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
2884 }
2885 }
2886 out
2887 }
2888
2889 #[tokio::test]
2893 async fn a_malformed_header_reason_is_truncated() {
2894 let v = validator("http://127.0.0.1:1/jwks");
2895 let header = format!(r#"{{"alg":"{}","typ":"JWT"}}"#, "A".repeat(8 * 1024));
2896 let token = format!("{}.e30.sig", b64url(header.as_bytes()));
2897 match v.validate(&token).await {
2898 Err(TokenRejection::Invalid(reason)) => {
2899 assert!(
2900 reason.starts_with("malformed token header: "),
2901 "{reason:.80}"
2902 );
2903 assert!(
2904 reason.chars().count() <= "malformed token header: ".len() + 129,
2905 "{} chars",
2906 reason.chars().count()
2907 );
2908 }
2909 other => panic!("expected Invalid, got {other:?}"),
2910 }
2911 }
2912
2913 fn mint_raw_header(header: serde_json::Value, claims: serde_json::Value) -> String {
2916 let input = format!(
2917 "{}.{}",
2918 b64url(header.to_string().as_bytes()),
2919 b64url(claims.to_string().as_bytes())
2920 );
2921 let key = jsonwebtoken::EncodingKey::from_rsa_pem(KEY_A_PEM.as_bytes()).unwrap();
2922 let signature =
2923 jsonwebtoken::crypto::sign(input.as_bytes(), &key, jsonwebtoken::Algorithm::RS256)
2924 .unwrap();
2925 format!("{input}.{signature}")
2926 }
2927
2928 #[tokio::test]
2933 async fn a_crit_header_is_refused_before_any_jwks_fetch() {
2934 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2935 let v = validator(&jwks.url);
2936 let c = claims(serde_json::json!({"scope": "mcp:read"}));
2937 for crit in [
2938 serde_json::json!(["urn:example:must-understand"]),
2939 serde_json::json!([]),
2940 serde_json::json!("not-an-array"),
2941 ] {
2942 let token = mint_raw_header(
2943 serde_json::json!({
2944 "alg": "RS256", "kid": KID_A, "crit": crit,
2945 "urn:example:must-understand": true,
2946 }),
2947 c.clone(),
2948 );
2949 crate::token::assert_invalid(
2950 v.validate(&token).await,
2951 InvalidTokenKind::CriticalHeader,
2952 "token header lists critical extensions (crit), none of which this \
2953 server supports",
2954 &format!("crit {crit}"),
2955 );
2956 }
2957 assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2958 let token = mint_raw_header(serde_json::json!({"alg": "RS256", "kid": KID_A}), c);
2961 assert!(v.validate(&token).await.is_ok());
2962 }
2963
2964 #[tokio::test]
2968 async fn a_null_kid_is_a_malformed_header_not_an_absent_one() {
2969 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2970 let v = validator(&jwks.url);
2971 let c = claims(serde_json::json!({"scope": "mcp:read"}));
2972 let token = mint_raw_header(serde_json::json!({"alg": "RS256", "kid": null}), c.clone());
2973 crate::token::assert_invalid(
2974 v.validate(&token).await,
2975 InvalidTokenKind::MalformedHeader,
2976 "malformed token header: kid is present but not a string",
2977 "kid null",
2978 );
2979 assert_eq!(jwks.hits.load(Ordering::SeqCst), 0);
2980 let token = mint_raw_header(serde_json::json!({"alg": "RS256"}), c);
2982 assert!(v.validate(&token).await.is_ok());
2983 }
2984
2985 #[tokio::test]
2990 async fn an_unreadable_registered_claim_is_malformed_claim_not_malformed_token() {
2991 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
2992 let v = validator(&jwks.url);
2993 let later = now() + 3600;
2994 for extra in [
2995 serde_json::json!({"nbf": [later]}),
2996 serde_json::json!({"exp": [later]}),
2997 serde_json::json!({"nbf": {"at": later}}),
2998 ] {
2999 let mut body = claims(serde_json::json!({"scope": "mcp:read"}));
3000 for (k, val) in extra.as_object().unwrap() {
3001 body[k] = val.clone();
3002 }
3003 let result = v.validate(&mint(KEY_A_PEM, KID_A, &body)).await;
3004 match result {
3005 Err(TokenRejection::Invalid(invalid)) => {
3006 assert_eq!(invalid.kind(), InvalidTokenKind::MalformedClaim, "{extra}")
3007 }
3008 other => panic!("{extra}: {other:?}"),
3009 }
3010 }
3011 let token = mint_raw_header(
3012 serde_json::json!({"alg": "RS256", "kid": KID_A}),
3013 serde_json::json!([1, 2]),
3014 );
3015 match v.validate(&token).await {
3016 Err(TokenRejection::Invalid(invalid)) => {
3017 assert_eq!(invalid.kind(), InvalidTokenKind::MalformedToken)
3018 }
3019 other => panic!("{other:?}"),
3020 }
3021 }
3022
3023 #[tokio::test]
3026 async fn an_nbf_that_is_not_a_numeric_date_is_refused() {
3027 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3028 let v = validator(&jwks.url);
3029 let later = now() + 365 * 24 * 3600;
3030 for nbf in [
3031 serde_json::json!(later.to_string()),
3032 serde_json::json!("later"),
3033 serde_json::json!(1e30),
3034 serde_json::json!(-5),
3035 serde_json::json!(null),
3036 ] {
3037 let token = mint(
3038 KEY_A_PEM,
3039 KID_A,
3040 &claims(serde_json::json!({"nbf": nbf, "scope": "mcp:read"})),
3041 );
3042 crate::token::assert_invalid(
3043 v.validate(&token).await,
3044 InvalidTokenKind::MalformedClaim,
3045 "token nbf is not a NumericDate (a non-negative number of seconds)",
3046 &format!("nbf {nbf}"),
3047 );
3048 }
3049 let token = mint(
3051 KEY_A_PEM,
3052 KID_A,
3053 &claims(serde_json::json!({"nbf": [later], "scope": "mcp:read"})),
3054 );
3055 assert!(is_invalid(&v.validate(&token).await));
3056 let past = mint(
3058 KEY_A_PEM,
3059 KID_A,
3060 &claims(serde_json::json!({"nbf": now() as f64 - 10.5, "scope": "mcp:read"})),
3061 );
3062 assert!(v.validate(&past).await.is_ok());
3063 let future = mint(
3064 KEY_A_PEM,
3065 KID_A,
3066 &claims(serde_json::json!({"nbf": later, "scope": "mcp:read"})),
3067 );
3068 match v.validate(&future).await {
3069 Err(TokenRejection::Invalid(reason)) => {
3070 assert!(reason.contains("ImmatureSignature"), "{reason}");
3071 }
3072 other => panic!("expected Invalid, got {other:?}"),
3073 }
3074 }
3075
3076 #[tokio::test]
3079 async fn a_sender_constrained_token_is_refused() {
3080 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3081 let v = validator(&jwks.url);
3082 for cnf in [
3083 serde_json::json!({"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"}),
3084 serde_json::json!({"x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"}),
3085 serde_json::json!(null),
3086 ] {
3087 let token = mint_with(
3088 crate::Algorithm::RS256,
3089 Some(KID_A),
3090 Some("at+jwt"),
3091 &claims(serde_json::json!({"cnf": cnf, "scope": "mcp:read"})),
3092 );
3093 crate::token::assert_invalid(
3094 v.validate(&token).await,
3095 InvalidTokenKind::SenderConstrained,
3096 "token is sender-constrained (cnf); this server accepts bearer tokens only",
3097 &format!("cnf {cnf}"),
3098 );
3099 }
3100 }
3101
3102 #[tokio::test]
3105 async fn the_jwks_is_fetched_once_and_cached() {
3106 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3107 let v = validator(&jwks.url);
3108 for _ in 0..3 {
3109 v.validate(&valid_token()).await.unwrap();
3110 }
3111 assert_eq!(
3112 jwks.hits.load(Ordering::SeqCst),
3113 1,
3114 "a cached key must not be re-fetched per request"
3115 );
3116 }
3117
3118 #[tokio::test]
3119 async fn an_unknown_kid_does_not_refetch_during_the_cooldown() {
3120 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3121 let v = validator(&jwks.url); let token = mint(
3125 KEY_A_PEM,
3126 "rotated-key",
3127 &claims(serde_json::json!({"scope": "mcp:read"})),
3128 );
3129 for _ in 0..5 {
3130 assert!(is_invalid(&v.validate(&token).await));
3131 }
3132 assert_eq!(
3133 jwks.hits.load(Ordering::SeqCst),
3134 1,
3135 "kid is attacker-controlled — five junk tokens must not mean five IdP hits"
3136 );
3137 }
3138
3139 #[tokio::test]
3140 async fn concurrent_unknown_kids_cost_one_fetch() {
3141 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3142 let v = Arc::new(validator(&jwks.url));
3143 let mut tasks = Vec::new();
3144 for i in 0..20 {
3145 let v = Arc::clone(&v);
3146 tasks.push(tokio::spawn(async move {
3147 let token = mint(
3148 KEY_A_PEM,
3149 &format!("junk-{i}"),
3150 &claims(serde_json::json!({"scope": "mcp:read"})),
3151 );
3152 v.validate(&token).await
3153 }));
3154 }
3155 for t in tasks {
3156 assert!(is_invalid(&t.await.unwrap()));
3157 }
3158 assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
3159 }
3160
3161 #[tokio::test]
3162 async fn an_unknown_kid_refetches_once_the_cooldown_has_passed() {
3163 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3164 let v = validator_no_cooldown(&jwks.url);
3165 let token = mint(
3166 KEY_A_PEM,
3167 "rotated-key",
3168 &claims(serde_json::json!({"scope": "mcp:read"})),
3169 );
3170 assert!(is_invalid(&v.validate(&token).await));
3171 assert!(is_invalid(&v.validate(&token).await));
3172 assert_eq!(
3173 jwks.hits.load(Ordering::SeqCst),
3174 2,
3175 "with the cooldown elapsed, an unknown kid must trigger a refresh — this \
3176 is how a rotated signing key is picked up without a restart"
3177 );
3178 }
3179
3180 #[tokio::test]
3181 async fn a_rotated_key_is_picked_up_and_a_withdrawn_key_is_dropped() {
3182 let jwks = spawn_http_server(HashMap::new(), None).await;
3183 let set = |body: String| {
3184 jwks.routes
3185 .lock()
3186 .unwrap()
3187 .insert("/jwks".to_string(), ("200 OK", body));
3188 };
3189 set(jwks_body());
3190 let v = validator_no_cooldown(&jwks.url);
3191 let c = claims(serde_json::json!({"scope": "mcp:read"}));
3192 let old = mint(KEY_A_PEM, KID_A, &c.clone());
3193 let new = mint_with(Algorithm::ES256, Some(KID_EC), None, &c);
3194
3195 assert!(v.validate(&old).await.is_ok());
3196 set(jwks_of(&[jwk_rsa_a(), jwk_ec()]));
3199 assert!(v.validate(&new).await.is_ok());
3200 assert!(v.validate(&old).await.is_ok());
3201 set(jwks_of(&[jwk_ec()]));
3204 assert_eq!(v.refresh_now().await.unwrap(), 1);
3205 assert!(is_invalid(&v.validate(&old).await));
3206 assert!(v.validate(&new).await.is_ok());
3207 }
3208
3209 #[tokio::test]
3210 async fn a_slow_refresh_does_not_stall_requests_whose_key_is_cached() {
3211 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3214 let v = Arc::new(validator_no_cooldown(&jwks.url));
3215 v.validate(&valid_token()).await.unwrap();
3216
3217 jwks.delay_ms.store(1500, Ordering::SeqCst);
3218 let background = Arc::clone(&v);
3219 let refresh = tokio::spawn(async move { background.refresh_now().await });
3220 let junk = Arc::clone(&v);
3222 let queued = tokio::spawn(async move {
3223 junk.validate(&mint(
3224 KEY_A_PEM,
3225 "unknown",
3226 &claims(serde_json::json!({"scope": "mcp:read"})),
3227 ))
3228 .await
3229 });
3230 tokio::time::sleep(Duration::from_millis(200)).await;
3231
3232 let fast = tokio::time::timeout(Duration::from_millis(500), v.validate(&valid_token()))
3233 .await
3234 .expect("a cached-key validation must not wait for the in-flight refresh");
3235 assert!(fast.is_ok());
3236 assert!(refresh.await.unwrap().is_ok());
3237 assert!(is_invalid(&queued.await.unwrap()));
3238 }
3239
3240 #[tokio::test]
3245 async fn a_dropped_validation_does_not_spend_the_refetch_cooldown() {
3246 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3247 jwks.delay_ms.store(500, Ordering::SeqCst);
3248 let v = validator(&jwks.url); assert!(
3250 tokio::time::timeout(Duration::from_millis(50), v.validate(&valid_token()))
3251 .await
3252 .is_err(),
3253 "the slow fetch outlives the caller"
3254 );
3255 jwks.delay_ms.store(0, Ordering::SeqCst);
3256 assert!(v.validate(&valid_token()).await.is_ok());
3259 assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
3260 }
3261
3262 #[tokio::test]
3263 async fn the_background_task_stops_when_the_validator_is_dropped() {
3264 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3265 let v = Arc::new(validator(&jwks.url));
3266 let task = v.spawn_background_refresh();
3267 for _ in 0..200 {
3268 if jwks.hits.load(Ordering::SeqCst) > 0 {
3269 break;
3270 }
3271 tokio::time::sleep(Duration::from_millis(10)).await;
3272 }
3273 assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
3274 let weak = Arc::downgrade(&v);
3275 drop(v);
3276 tokio::time::timeout(Duration::from_secs(5), task)
3277 .await
3278 .expect("the task ends with the validator, not after its hour-long sleep")
3279 .unwrap();
3280 assert!(
3281 weak.upgrade().is_none(),
3282 "the task held no strong reference"
3283 );
3284 }
3285
3286 #[tokio::test]
3287 async fn a_failed_refresh_keeps_the_keys_already_held() {
3288 let jwks = spawn_http_server(HashMap::new(), None).await;
3289 jwks.routes
3290 .lock()
3291 .unwrap()
3292 .insert("/jwks".to_string(), ("200 OK", jwks_body()));
3293 let v = validator_no_cooldown(&jwks.url);
3294 assert!(v.validate(&valid_token()).await.is_ok());
3295 jwks.routes.lock().unwrap().insert(
3296 "/jwks".to_string(),
3297 ("503 Service Unavailable", "{}".into()),
3298 );
3299 assert!(v.refresh_now().await.is_err());
3300 assert!(
3301 v.validate(&valid_token()).await.is_ok(),
3302 "an IdP outage must not revoke keys that are still good"
3303 );
3304 }
3305
3306 #[tokio::test]
3309 async fn the_status_before_any_load_is_empty_and_does_no_io() {
3310 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3311 let v = validator(&jwks.url);
3312 for _ in 0..10 {
3313 let status = v.key_set_status();
3314 assert_eq!(status.keys, 0);
3315 assert_eq!(status.jwks_uri.as_deref(), Some(jwks.url.as_str()));
3316 assert_eq!(status.last_attempt, None);
3317 assert_eq!(status.last_success, None);
3318 assert_eq!(status.last_error, None);
3319 assert!(!status.is_ready());
3320 assert!(!v.is_ready());
3321 }
3322 assert_eq!(
3323 jwks.hits.load(Ordering::SeqCst),
3324 0,
3325 "reading the status must never fetch"
3326 );
3327 }
3328
3329 #[tokio::test]
3330 async fn the_status_tracks_a_success_and_a_failure_keeps_the_keys() {
3331 let jwks = spawn_http_server(HashMap::new(), None).await;
3332 let set = |status: &'static str, body: String| {
3333 jwks.routes
3334 .lock()
3335 .unwrap()
3336 .insert("/jwks".to_string(), (status, body));
3337 };
3338 set("200 OK", jwks_of(&[jwk_rsa_a(), jwk_ec()]));
3339 let v = validator_no_cooldown(&jwks.url);
3340
3341 let before = std::time::SystemTime::now();
3342 assert_eq!(v.refresh_now().await.unwrap(), 2);
3343 let ok = v.key_set_status();
3344 assert_eq!(ok.keys, 2);
3345 assert!(ok.is_ready() && v.is_ready());
3346 assert_eq!(ok.jwks_uri.as_deref(), Some(jwks.url.as_str()));
3347 let success = ok.last_success.expect("a success is recorded");
3348 let attempt = ok.last_attempt.expect("the attempt is recorded");
3349 assert!(before <= attempt && attempt <= success);
3350 assert_eq!(ok.last_error, None);
3351
3352 set("503 Service Unavailable", "{}".into());
3353 let err = v.refresh_now().await.unwrap_err();
3354 let failed = v.key_set_status();
3355 assert_eq!(failed.last_error.as_ref(), Some(&err));
3356 assert_eq!(err.kind(), RefreshErrorKind::Fetch);
3357 assert_eq!(failed.keys, 2, "a failed refresh keeps the keys held");
3358 assert!(failed.is_ready() && v.is_ready());
3359 assert_eq!(failed.last_success, Some(success), "unchanged by a failure");
3360 assert!(failed.last_attempt.unwrap() >= success);
3361
3362 let hits = jwks.hits.load(Ordering::SeqCst);
3364 for _ in 0..10 {
3365 let _ = v.key_set_status();
3366 let _ = v.is_ready();
3367 }
3368 assert_eq!(jwks.hits.load(Ordering::SeqCst), hits);
3369
3370 set("200 OK", jwks_body());
3372 assert_eq!(v.refresh_now().await.unwrap(), 1);
3373 let recovered = v.key_set_status();
3374 assert_eq!(recovered.last_error, None);
3375 assert_eq!(recovered.keys, 1);
3376 assert!(recovered.last_success.unwrap() >= success);
3377 }
3378
3379 #[tokio::test]
3380 async fn a_failed_first_load_leaves_the_validator_not_ready_with_the_error_kind() {
3381 let no_usable_keys = jwks_of(&[serde_json::json!({"kty": "oct", "k": "c2VjcmV0"})]);
3382 for (status_line, body, kind) in [
3383 (
3384 "503 Service Unavailable",
3385 "{}".to_string(),
3386 RefreshErrorKind::Fetch,
3387 ),
3388 ("200 OK", "not json".to_string(), RefreshErrorKind::Parse),
3389 (
3390 "200 OK",
3391 "{\"no\": \"keys\"}".to_string(),
3392 RefreshErrorKind::Parse,
3393 ),
3394 ("200 OK", no_usable_keys, RefreshErrorKind::NoUsableKeys),
3395 ] {
3396 let jwks = spawn_jwks_server(status_line, body).await;
3397 let v = validator(&jwks.url);
3398 let err = v.refresh_now().await.unwrap_err();
3399 let status = v.key_set_status();
3400 assert_eq!(err.kind(), kind, "{err}");
3401 assert_eq!(status.last_error, Some(err));
3402 assert_eq!(status.keys, 0);
3403 assert!(status.last_attempt.is_some());
3404 assert_eq!(status.last_success, None);
3405 assert!(!v.is_ready());
3406 }
3407 }
3408
3409 #[tokio::test]
3410 async fn the_status_reports_the_discovered_jwks_uri_and_discovery_failures() {
3411 let (server, issuer) =
3412 discovery_server("/application/o/wiki/", |i| i.to_string(), false).await;
3413 let v = discovering_validator(&issuer);
3414 assert_eq!(v.key_set_status().jwks_uri, None, "not yet discovered");
3415 v.refresh_now().await.unwrap();
3416 let status = v.key_set_status();
3417 assert_eq!(
3418 status.jwks_uri.as_deref(),
3419 Some(format!("{}/keys", server.base).as_str())
3420 );
3421 assert!(status.is_ready());
3422
3423 let (_server, issuer) =
3424 discovery_server("/app/", |_| "https://other.test/".into(), false).await;
3425 let v = discovering_validator(&issuer);
3426 let err = v.refresh_now().await.unwrap_err();
3427 assert_eq!(err.kind(), RefreshErrorKind::Discovery);
3428 let status = v.key_set_status();
3429 assert_eq!(status.jwks_uri, None);
3430 assert_eq!(status.last_error, Some(err));
3431 }
3432
3433 #[tokio::test]
3439 async fn a_credential_in_the_jwks_uri_never_reaches_the_status_or_errors() {
3440 let jwks = spawn_jwks_server("503 Service Unavailable", "{}".into()).await;
3441 let uri = format!(
3442 "{}?key=t0ken",
3443 jwks.url.replacen("http://", "http://alice:s3cret@", 1)
3444 );
3445 let shown = format!("{}?***", jwks.url.replacen("http://", "http://***@", 1));
3446 let v = validator_no_cooldown(&uri);
3447
3448 assert_eq!(v.key_set_status().jwks_uri.as_deref(), Some(shown.as_str()));
3449 let err = v.refresh_now().await.unwrap_err();
3450 let Err(TokenRejection::Invalid(reason)) = v.validate(&valid_token()).await else {
3451 panic!("no key can be loaded");
3452 };
3453 assert_eq!(jwks.hits.load(Ordering::SeqCst), 2, "both fetches went out");
3454 assert_eq!(reason.kind(), InvalidTokenKind::KeySetUnavailable);
3455 let reason = reason.to_string();
3456
3457 let status = v.key_set_status();
3458 assert_eq!(status.jwks_uri.as_deref(), Some(shown.as_str()));
3459 assert!(err.to_string().contains(&shown), "{err}");
3460 for text in [
3461 err.to_string(),
3462 format!("{err:?}"),
3463 status.last_error.as_ref().unwrap().to_string(),
3464 format!("{status:?}"),
3465 reason,
3466 ] {
3467 for secret in ["alice", "s3cret", "t0ken"] {
3468 assert!(!text.contains(secret), "{secret} leaked into: {text}");
3469 }
3470 }
3471 }
3472
3473 #[tokio::test]
3480 async fn the_status_does_not_wait_on_a_slow_refresh_in_flight() {
3481 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3482 let v = Arc::new(validator_no_cooldown(&jwks.url));
3483 v.refresh_now().await.unwrap();
3484 let loaded = v.key_set_status();
3485
3486 jwks.hold.store(true, Ordering::SeqCst);
3487 let background = Arc::clone(&v);
3488 let refresh = tokio::spawn(async move { background.refresh_now().await });
3489 while !(v.keys.refresh_in_flight() && jwks.hits.load(Ordering::SeqCst) == 2) {
3491 tokio::task::yield_now().await;
3492 }
3493
3494 let probe = Arc::clone(&v);
3498 let (sent, received) = std::sync::mpsc::channel();
3499 std::thread::spawn(move || {
3500 let _ = sent.send((probe.key_set_status(), probe.is_ready()));
3501 });
3502 let (status, ready) = received
3503 .recv_timeout(Duration::from_secs(10))
3504 .expect("the status read waited on the refresh in flight");
3505 assert!(
3506 v.keys.refresh_in_flight(),
3507 "read while the fetch was in flight"
3508 );
3509 assert!(!refresh.is_finished());
3510 assert_eq!(
3511 jwks.hits.load(Ordering::SeqCst),
3512 2,
3513 "reading the status fetched nothing"
3514 );
3515 assert!(ready);
3516 assert_eq!(status.keys, 1);
3517 assert_eq!(status.last_success, loaded.last_success);
3518 assert!(status.last_attempt > loaded.last_attempt);
3519 assert_eq!(status.last_error, None);
3520
3521 jwks.release.notify_one();
3522 assert!(refresh.await.unwrap().is_ok());
3523 assert_eq!(jwks.hits.load(Ordering::SeqCst), 2);
3524 assert!(v.key_set_status().last_success > loaded.last_success);
3525 }
3526
3527 async fn settle(v: &OAuthValidator) {
3533 for _ in 0..100 {
3534 tokio::task::yield_now().await;
3535 }
3536 for _ in 0..100_000 {
3537 if !v.keys.refresh_in_flight() {
3538 break;
3539 }
3540 tokio::task::yield_now().await;
3541 }
3542 for _ in 0..100 {
3543 tokio::task::yield_now().await;
3544 }
3545 }
3546
3547 async fn hits_after(v: &OAuthValidator, jwks: &FakeJwksServer, secs: f64) -> usize {
3550 tokio::time::advance(Duration::from_secs_f64(secs)).await;
3551 settle(v).await;
3552 jwks.hits.load(Ordering::SeqCst)
3553 }
3554
3555 async fn next_attempt_after(v: &OAuthValidator, jwks: &FakeJwksServer, gap: f64) {
3560 let before = jwks.hits.load(Ordering::SeqCst);
3561 assert_eq!(
3562 hits_after(v, jwks, gap - 0.5).await,
3563 before,
3564 "no attempt before {gap}s"
3565 );
3566 assert_eq!(
3567 hits_after(v, jwks, 1.0).await,
3568 before + 1,
3569 "an attempt at {gap}s"
3570 );
3571 }
3572
3573 #[tokio::test(start_paused = true)]
3574 async fn a_failed_first_load_is_retried_quickly_until_keys_are_held() {
3575 let jwks = spawn_http_server(HashMap::new(), None).await;
3576 let set = |status: &'static str, body: String| {
3577 jwks.routes
3578 .lock()
3579 .unwrap()
3580 .insert("/jwks".to_string(), (status, body));
3581 };
3582 set("503 Service Unavailable", "{}".into());
3583 let v = Arc::new(validator(&jwks.url)); let task = v.spawn_background_refresh();
3585 settle(&v).await;
3586 assert_eq!(jwks.hits.load(Ordering::SeqCst), 1, "the first load");
3587 assert!(!v.is_ready());
3588
3589 for gap in [5.0, 10.0, 20.0, 40.0, 80.0, 160.0, 300.0, 300.0] {
3591 next_attempt_after(&v, &jwks, gap).await;
3592 }
3593 assert!(!v.is_ready());
3594 assert_eq!(
3595 v.key_set_status().last_error.map(|e| e.kind()),
3596 Some(RefreshErrorKind::Fetch)
3597 );
3598
3599 set("200 OK", jwks_body());
3602 next_attempt_after(&v, &jwks, 300.0).await;
3603 assert!(v.is_ready());
3604 assert_eq!(v.key_set_status().last_error, None);
3605
3606 set("503 Service Unavailable", "{}".into());
3609 next_attempt_after(&v, &jwks, 3600.0).await;
3610 next_attempt_after(&v, &jwks, 60.0).await;
3611 next_attempt_after(&v, &jwks, 120.0).await;
3612 assert!(v.is_ready(), "the failures kept the keys");
3613
3614 task.abort();
3615 }
3616
3617 #[tokio::test]
3618 async fn an_unreachable_jwks_endpoint_fails_closed() {
3619 let v = validator("http://127.0.0.1:1/jwks");
3621 assert!(
3622 is_invalid(&v.validate(&valid_token()).await),
3623 "an IdP we cannot reach must mean 'no', never 'sure'"
3624 );
3625 }
3626
3627 #[tokio::test]
3628 async fn a_jwks_error_response_fails_closed() {
3629 let jwks = spawn_jwks_server("500 Internal Server Error", "{}".into()).await;
3630 let v = validator(&jwks.url);
3631 assert!(is_invalid(&v.validate(&valid_token()).await));
3632 let err = v.refresh_now().await.unwrap_err().to_string();
3634 assert!(
3635 err.starts_with(&format!(
3636 "fetching the JWKS from {}: non-success status: ",
3637 jwks.url
3638 )),
3639 "{err}"
3640 );
3641 assert!(err.contains("500 Internal Server Error"), "{err}");
3642 }
3643
3644 #[tokio::test]
3645 async fn an_oversized_jwks_response_fails_closed() {
3646 let padding = "x".repeat(MAX_FETCH_BYTES);
3647 let body = format!("{{\"keys\":[{}],\"padding\":\"{padding}\"}}", jwk_rsa_a());
3648 let jwks = spawn_jwks_server("200 OK", body).await;
3649 let v = validator(&jwks.url);
3650 assert!(is_invalid(&v.validate(&valid_token()).await));
3651 }
3652
3653 #[tokio::test]
3654 async fn a_key_set_with_no_usable_keys_fails_closed() {
3655 let body = jwks_of(&[
3656 serde_json::json!({"kty": "RSA", "use": "enc", "kid": KID_A, "n": N_A, "e": "AQAB"}),
3659 serde_json::json!({"kty": "oct", "kid": "hmac", "k": "c2VjcmV0"}),
3660 serde_json::json!({"kty": "EC", "crv": "P-521", "kid": "p521", "x": "AA", "y": "AA"}),
3661 serde_json::json!({"kty": "RSA", "alg": "ES256", "kid": KID_A, "n": N_A, "e": "AQAB"}),
3662 ]);
3663 let jwks = spawn_jwks_server("200 OK", body).await;
3664 let v = validator(&jwks.url);
3665 assert!(is_invalid(&v.validate(&valid_token()).await));
3666 assert!(
3667 v.refresh_now()
3668 .await
3669 .unwrap_err()
3670 .to_string()
3671 .contains("fetching the JWKS")
3672 );
3673 }
3674
3675 #[tokio::test]
3676 async fn one_unparseable_key_does_not_take_the_usable_ones_down() {
3677 let body = jwks_of(&[
3678 serde_json::json!({"kty": "OKP", "crv": "X25519", "kid": "x", "x": "AA"}),
3679 serde_json::json!({"kty": "weird", "kid": "w"}),
3680 jwk_rsa_a(),
3681 ]);
3682 let jwks = spawn_jwks_server("200 OK", body).await;
3683 let v = validator(&jwks.url);
3684 assert!(v.validate(&valid_token()).await.is_ok());
3685 }
3686
3687 #[tokio::test]
3688 async fn a_kid_less_header_uses_the_single_compatible_key() {
3689 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3690 let v = validator(&jwks.url);
3691 let c = claims(serde_json::json!({"scope": "mcp:read"}));
3692 let token = mint_with(Algorithm::RS256, None, None, &c.clone());
3693 assert!(v.validate(&token).await.is_ok());
3694
3695 let jwks = spawn_jwks_server(
3697 "200 OK",
3698 jwks_of(&[jwk_rsa_a(), jwk_rsa_a_any_alg("second")]),
3699 )
3700 .await;
3701 let v = validator(&jwks.url);
3702 assert!(is_invalid(&v.validate(&token).await));
3703 }
3704
3705 async fn discovery_server(
3710 issuer_path: &str,
3711 doc_issuer: impl Fn(&str) -> String,
3712 via_rfc8414: bool,
3713 ) -> (FakeJwksServer, String) {
3714 let server = spawn_http_server(HashMap::new(), None).await;
3715 let issuer = format!("{}{issuer_path}", server.base);
3716 let doc = serde_json::json!({
3717 "issuer": doc_issuer(&issuer),
3718 "jwks_uri": format!("{}/keys", server.base),
3719 })
3720 .to_string();
3721 let well_known = if via_rfc8414 {
3722 format!(
3723 "/.well-known/oauth-authorization-server{}",
3724 issuer_path.trim_end_matches('/')
3725 )
3726 } else {
3727 format!(
3728 "{}/.well-known/openid-configuration",
3729 issuer_path.trim_end_matches('/')
3730 )
3731 };
3732 {
3733 let mut routes = server.routes.lock().unwrap();
3734 routes.insert(well_known, ("200 OK", doc));
3735 routes.insert("/keys".to_string(), ("200 OK", jwks_body()));
3736 }
3737 (server, issuer)
3738 }
3739
3740 fn discovering_validator(issuer: &str) -> OAuthValidator {
3741 let mut cfg = oauth_config("");
3742 cfg.issuer = issuer.to_string();
3743 validator_with(cfg)
3744 }
3745
3746 fn token_from(issuer: &str) -> String {
3747 mint(
3748 KEY_A_PEM,
3749 KID_A,
3750 &claims(serde_json::json!({"iss": issuer, "scope": "mcp:read"})),
3751 )
3752 }
3753
3754 #[tokio::test]
3755 async fn an_omitted_jwks_uri_is_discovered_once_from_oidc_metadata() {
3756 let (server, issuer) =
3758 discovery_server("/application/o/wiki/", |i| i.to_string(), false).await;
3759 let v = discovering_validator(&issuer);
3760 for _ in 0..3 {
3761 assert!(v.validate(&token_from(&issuer)).await.is_ok());
3762 }
3763 assert_eq!(
3764 server.hits.load(Ordering::SeqCst),
3765 2,
3766 "one discovery fetch and one JWKS fetch, then cached"
3767 );
3768 }
3769
3770 #[tokio::test]
3771 async fn discovery_falls_back_to_rfc_8414_metadata() {
3772 let (_server, issuer) = discovery_server("/tenant", |i| i.to_string(), true).await;
3773 let v = discovering_validator(&issuer);
3774 assert!(v.validate(&token_from(&issuer)).await.is_ok());
3775 }
3776
3777 #[tokio::test]
3778 async fn a_discovery_document_for_a_different_issuer_is_refused() {
3779 let (server, issuer) = discovery_server(
3781 "/application/o/wiki/",
3782 |i| i.trim_end_matches('/').to_string(),
3783 false,
3784 )
3785 .await;
3786 let v = discovering_validator(&issuer);
3787 assert!(is_invalid(&v.validate(&token_from(&issuer)).await));
3788 let err = v.refresh_now().await.unwrap_err().to_string();
3789 assert!(err.contains("does not match mcp.oauth.issuer"), "{err}");
3790 assert!(
3791 err.starts_with("could not discover a jwks_uri for mcp.oauth.issuer "),
3792 "{err}"
3793 );
3794 assert!(err.contains("set mcp.oauth.jwks_uri explicitly"), "{err}");
3795 assert_eq!(server.hits.load(Ordering::SeqCst), 4);
3798 }
3799
3800 #[test]
3801 fn loopback_detection() {
3802 let url = |s: &str| reqwest::Url::parse(s).unwrap();
3803 assert!(is_loopback_url(&url("http://127.0.0.1:8080/x")));
3804 assert!(is_loopback_url(&url("http://[::1]:8080/x")));
3805 assert!(is_loopback_url(&url("http://localhost/x")));
3806 assert!(is_loopback_url(&url("http:/localhost/x")));
3807 assert!(is_loopback_url(&url("http://app.localhost/x")));
3808 assert!(!is_loopback_url(&url("http://auth.example.com/x")));
3809 assert!(!is_loopback_url(&url("http:auth.example.com/x")));
3810 assert!(!is_loopback_url(&url("data:text/plain,x")));
3811 }
3812
3813 #[tokio::test]
3814 async fn a_loopback_issuer_cannot_discover_a_cleartext_non_loopback_jwks_uri() {
3815 let server = spawn_http_server(HashMap::new(), None).await;
3818 let issuer = format!("{}/app/", server.base);
3819 let doc =
3820 serde_json::json!({"issuer": issuer, "jwks_uri": "http://idp.example.invalid/keys"})
3821 .to_string();
3822 server.routes.lock().unwrap().insert(
3823 "/app/.well-known/openid-configuration".to_string(),
3824 ("200 OK", doc),
3825 );
3826 let v = discovering_validator(&issuer);
3827 let err = v.refresh_now().await.unwrap_err().to_string();
3828 assert!(err.contains("plain http on a non-loopback host"), "{err}");
3829 assert!(err.contains("mcp.oauth.allow_insecure_http"), "{err}");
3830 }
3831
3832 #[tokio::test]
3836 async fn a_localhost_name_is_fetched_from_this_host_whatever_dns_says() {
3837 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
3838 for host in ["nonexistent-name.localhost", "localhost", "a.b.localhost"] {
3839 let url = jwks.url.replace("127.0.0.1", host);
3840 let v = validator_no_cooldown(&url);
3841 assert_eq!(v.refresh_now().await.unwrap(), 1, "{url}");
3842 }
3843 assert_eq!(jwks.hits.load(Ordering::SeqCst), 3);
3844 }
3845
3846 #[tokio::test]
3851 async fn a_discovered_jwks_uri_that_fails_is_rediscovered() {
3852 let server = spawn_http_server(HashMap::new(), None).await;
3853 let issuer = format!("{}/app/", server.base);
3854 let doc = |path: &str| {
3855 serde_json::json!({"issuer": issuer, "jwks_uri": format!("{}{path}", server.base)})
3856 .to_string()
3857 };
3858 let set = |path: &str, status: &'static str, body: String| {
3859 server
3860 .routes
3861 .lock()
3862 .unwrap()
3863 .insert(path.to_string(), (status, body));
3864 };
3865 let hits = |path: &str| *server.path_hits.lock().unwrap().get(path).unwrap_or(&0);
3866 const METADATA: &str = "/app/.well-known/openid-configuration";
3867 set(METADATA, "200 OK", doc("/keys"));
3868 set("/keys", "200 OK", jwks_body());
3869 let mut cfg = oauth_config("");
3870 cfg.issuer = issuer.clone();
3871 let v = OAuthValidator::build(&cfg, Duration::ZERO).unwrap();
3872 assert_eq!(v.refresh_now().await.unwrap(), 1);
3873 assert_eq!(hits(METADATA), 1);
3874
3875 set(METADATA, "200 OK", doc("/keys2"));
3877 set("/keys2", "200 OK", jwks_of(&[jwk_ec()]));
3878 set("/keys", "404 Not Found", "{}".into());
3879 let err = v.refresh_now().await.unwrap_err();
3880 assert_eq!(err.kind(), RefreshErrorKind::Fetch);
3881 assert_eq!(
3883 v.key_set_status().jwks_uri.as_deref(),
3884 Some(format!("{}/keys", server.base).as_str())
3885 );
3886 assert_eq!(v.key_set_status().keys, 1, "held keys are kept");
3887 assert_eq!(v.refresh_now().await.unwrap(), 1);
3888 assert_eq!(hits(METADATA), 2, "the metadata was read again");
3889 assert_eq!(hits("/keys2"), 1);
3890 assert_eq!(
3891 v.key_set_status().jwks_uri.as_deref(),
3892 Some(format!("{}/keys2", server.base).as_str())
3893 );
3894
3895 let v = validator_no_cooldown(&format!("{}/keys", server.base));
3897 assert!(v.refresh_now().await.is_err());
3898 assert!(v.refresh_now().await.is_err());
3899 assert_eq!(hits(METADATA), 2);
3900 }
3901
3902 #[tokio::test]
3905 async fn a_non_success_status_is_refused_even_with_a_jwks_body() {
3906 for status in [
3907 "302 Found",
3908 "304 Not Modified",
3909 "404 Not Found",
3910 "500 Internal Server Error",
3911 ] {
3912 let server = spawn_jwks_server(status, jwks_body()).await;
3913 let v = validator_no_cooldown(&server.url);
3914 let err = v.refresh_now().await.unwrap_err();
3915 assert_eq!(err.kind(), RefreshErrorKind::Fetch, "{status}");
3916 assert!(
3917 err.to_string().contains("non-success status"),
3918 "{status}: {err}"
3919 );
3920 assert!(!v.is_ready(), "{status}");
3921 }
3922 }
3923
3924 #[tokio::test]
3929 async fn an_rsa_key_that_cannot_verify_is_not_counted_as_held() {
3930 let mut empty_e = jwk_rsa_a();
3931 empty_e["e"] = serde_json::json!("");
3932 let mut short_n = jwk_rsa_a();
3933 short_n["n"] = serde_json::json!(b64url(&[0xc5; 255]));
3934 let server = spawn_jwks_server("200 OK", jwks_of(&[empty_e.clone(), short_n])).await;
3935 let v = validator_no_cooldown(&server.url);
3936 let err = v.refresh_now().await.unwrap_err();
3937 assert_eq!(err.kind(), RefreshErrorKind::NoUsableKeys);
3938 assert_eq!(v.key_set_status().keys, 0);
3939 assert!(!v.is_ready());
3940 let server = spawn_jwks_server("200 OK", jwks_of(&[empty_e, jwk_rsa_a()])).await;
3942 let v = validator_no_cooldown(&server.url);
3943 assert_eq!(v.refresh_now().await.unwrap(), 1);
3944 assert!(v.validate(&valid_token()).await.is_ok());
3945 }
3946
3947 #[tokio::test]
3948 async fn a_redirect_from_loopback_to_a_non_loopback_host_is_refused() {
3949 for (target, allow_insecure_http) in [
3953 ("http://idp.example.invalid/keys", false),
3954 ("http://idp.example.invalid/keys", true),
3955 ("https://idp.example.invalid/keys", false),
3956 ] {
3957 let status: &'static str =
3958 Box::leak(format!("302 Found\r\nLocation: {target}").into_boxed_str());
3959 let server = spawn_http_server(
3960 HashMap::from([("/jwks".to_string(), (status, String::new()))]),
3961 None,
3962 )
3963 .await;
3964 let mut cfg = oauth_config(&server.url);
3965 cfg.allow_insecure_http = allow_insecure_http;
3966 let v = validator_with(cfg);
3967 let err = v.refresh_now().await.unwrap_err();
3968 assert_eq!(err.kind(), RefreshErrorKind::Fetch, "{target}");
3969 let err = err.to_string();
3970 assert!(
3971 err.contains("redirect from a loopback URL to a non-loopback host"),
3972 "{target}: {err}"
3973 );
3974 assert_eq!(server.hits.load(Ordering::SeqCst), 1, "{target}");
3975 }
3976 }
3977
3978 #[test]
3979 fn required_scopes_are_unadvertised_only_against_a_non_empty_menu() {
3980 let mut cfg = oauth_config("http://127.0.0.1/jwks");
3981 cfg.required_scopes = vec!["mcp:read".to_string()];
3982 cfg.scopes_supported = vec!["mcp:write".to_string()];
3983 assert_eq!(unadvertised_scopes(&cfg), ["mcp:read"]);
3984 cfg.scopes_supported = vec!["mcp:read".to_string(), "mcp:write".to_string()];
3985 assert!(unadvertised_scopes(&cfg).is_empty());
3986 cfg.scopes_supported = Vec::new();
3989 assert!(unadvertised_scopes(&cfg).is_empty());
3990 let challenge = OAuthValidator::new(&cfg).unwrap().invalid_token_challenge();
3991 assert!(challenge.contains(r#"scope="mcp:read""#), "{challenge}");
3992 }
3993
3994 fn production_authentik_config(issuer: &str) -> crate::OAuthConfig {
4000 #[cfg(feature = "serde")]
4001 {
4002 let yaml = format!(
4003 "enabled: true\n\
4004 issuer: \"{issuer}\"\n\
4005 jwks_uri: \"{issuer}jwks/\"\n\
4006 audience: \"example-client-id\"\n\
4007 resource: \"https://kb.example.com/mcp\"\n\
4008 required_scope: \"mcp:read\"\n\
4009 scopes_supported: [\"mcp:read\", \"mcp:write\"]\n"
4010 );
4011 serde_yaml_ng::from_str(&yaml).unwrap()
4012 }
4013 #[cfg(not(feature = "serde"))]
4014 {
4015 crate::OAuthConfig {
4016 enabled: true,
4017 issuer: issuer.to_string(),
4018 jwks_uri: Some(format!("{issuer}jwks/")),
4019 audience: "example-client-id".into(),
4020 resource: "https://kb.example.com/mcp".into(),
4021 required_scope: Some("mcp:read".into()),
4022 scopes_supported: Some(vec!["mcp:read".into(), "mcp:write".into()]),
4023 ..crate::OAuthConfig::default()
4024 }
4025 }
4026 }
4027
4028 #[tokio::test]
4035 async fn production_authentik_config_and_token_still_pass_unchanged() {
4036 let server = spawn_http_server(HashMap::new(), None).await;
4037 let issuer = format!("{}/application/o/example-app/", server.base);
4038 server.routes.lock().unwrap().insert(
4039 "/application/o/example-app/jwks/".to_string(),
4040 ("200 OK", jwks_body()),
4041 );
4042 let parsed = production_authentik_config(&issuer);
4043 let cfg = parsed
4044 .resolve(crate::KeyNaming::Dotted("mcp.oauth"))
4045 .unwrap()
4046 .expect("enabled");
4047 assert!(
4048 cfg.accept_static_bearer,
4049 "dual mode must stay on by default"
4050 );
4051 assert_eq!(cfg.required_scopes, ["mcp:read"]);
4052 let v = validator_with(cfg);
4053
4054 let token = mint_with(
4055 Algorithm::RS256,
4056 Some(KID_A),
4057 Some("JWT"),
4058 &serde_json::json!({
4059 "iss": issuer,
4060 "sub": "0000000000000000example",
4061 "aud": "example-client-id",
4062 "azp": "example-client-id",
4063 "exp": now() + 300,
4064 "iat": now(),
4065 "auth_time": now(),
4066 "acr": "goauthentik.io/providers/oauth2/default",
4067 "email": "user@example.com",
4068 "email_verified": true,
4069 "name": "Example User",
4070 "given_name": "Example User",
4071 "preferred_username": "example",
4072 "nickname": "example",
4073 "groups": ["wiki-users"],
4074 "scope": "openid email profile mcp:read mcp:write",
4075 }),
4076 );
4077 let t = v.validate(&token).await.unwrap();
4078 assert_eq!(t.principal.as_deref(), Some("example"));
4079 assert_eq!(
4080 t.scopes,
4081 ["openid", "email", "profile", "mcp:read", "mcp:write"]
4082 );
4083 assert_eq!(v.metadata()["authorization_servers"][0], issuer.as_str());
4086 assert!(
4087 v.invalid_token_challenge()
4088 .starts_with("Bearer error=\"invalid_token\", resource_metadata=")
4089 );
4090 assert_eq!(
4091 v.insufficient_scope_challenge(),
4092 "Bearer error=\"insufficient_scope\", scope=\"mcp:read\", \
4093 resource_metadata=\"https://kb.example.com/.well-known/oauth-protected-resource/mcp\""
4094 );
4095 }
4096
4097 async fn accepts(
4103 cfg_edit: impl FnOnce(&mut ResolvedOAuthConfig),
4104 alg: Algorithm,
4105 kid: &str,
4106 typ: Option<&str>,
4107 token_claims: serde_json::Value,
4108 ) -> AuthorizedToken {
4109 let jwks = spawn_jwks_server("200 OK", jwks_body_all()).await;
4110 let mut cfg = oauth_config(&jwks.url);
4111 cfg_edit(&mut cfg);
4112 let v = validator_with(cfg);
4113 v.validate(&mint_with(alg, Some(kid), typ, &token_claims))
4114 .await
4115 .unwrap()
4116 }
4117
4118 #[tokio::test]
4119 async fn observed_shape_authelia_4_39_scp_array_and_resource_url_audience() {
4120 let issuer = "https://auth.example.com";
4121 let resource = "https://kb.example.com/mcp";
4122 let t = accepts(
4123 |c| {
4124 c.issuer = issuer.into();
4125 c.audience = resource.into();
4126 c.require_at_jwt = true;
4127 },
4128 Algorithm::RS256,
4129 "test-key-a-pss",
4130 Some("at+jwt"),
4131 serde_json::json!({
4132 "iss": issuer, "aud": [resource], "client_id": "example-client",
4133 "sub": "44726d41-0000-4000-8000-000000000000",
4134 "exp": now() + 3600, "iat": now(), "nbf": now(),
4135 "jti": "x", "scp": ["mcp:read", "mcp:write"],
4136 }),
4137 )
4138 .await;
4139 assert_eq!(t.scopes, ["mcp:read", "mcp:write"]);
4140 assert_eq!(
4142 t.principal.as_deref(),
4143 Some("44726d41-0000-4000-8000-000000000000")
4144 );
4145 }
4146
4147 #[tokio::test]
4148 async fn observed_shape_kanidm_es256_per_client_issuer_and_client_audience() {
4149 let issuer = "https://idm.example.com/oauth2/openid/example-client";
4150 let t = accepts(
4151 |c| {
4152 c.issuer = issuer.into();
4153 c.audience = "example-client".into();
4154 c.require_at_jwt = true;
4155 },
4156 Algorithm::ES256,
4157 KID_EC,
4158 Some("at+jwt"),
4159 serde_json::json!({
4160 "iss": issuer, "aud": "example-client", "client_id": "example-client",
4161 "sub": "00000000-0000-4000-8000-000000000001",
4162 "exp": now() + 900, "iat": now(), "nbf": now(), "jti": "x",
4163 "scope": "mcp:read openid profile",
4164 }),
4165 )
4166 .await;
4167 assert!(t.has_scope("mcp:read"));
4168 }
4169
4170 #[tokio::test]
4179 async fn documented_shape_fixture_not_live_tested_keycloak() {
4180 let issuer = "https://sso.example.com/realms/home";
4183 let t = accepts(
4184 |c| {
4185 c.issuer = issuer.into();
4186 c.audience = "wiki".into();
4187 },
4188 Algorithm::RS256,
4189 KID_A,
4190 Some("JWT"),
4191 serde_json::json!({
4192 "iss": issuer, "aud": ["wiki", "account"], "azp": "wiki",
4193 "sub": "u", "exp": now() + 300, "typ": "Bearer",
4194 "preferred_username": "alice", "scope": "openid profile mcp:read",
4195 }),
4196 )
4197 .await;
4198 assert_eq!(t.principal.as_deref(), Some("alice"));
4199 }
4200
4201 #[tokio::test]
4202 async fn documented_shape_fixture_not_live_tested_okta_custom_as() {
4203 let issuer = "https://example.okta.com/oauth2/default";
4206 let t = accepts(
4207 |c| {
4208 c.issuer = issuer.into();
4209 c.audience = "api://default".into();
4210 },
4211 Algorithm::RS256,
4212 KID_A,
4213 None,
4214 serde_json::json!({
4215 "iss": issuer, "aud": "api://default", "cid": "client", "sub": "a@example.com",
4216 "exp": now() + 3600, "scp": ["openid", "mcp:read"],
4217 }),
4218 )
4219 .await;
4220 assert!(t.has_scope("mcp:read"));
4221 }
4222
4223 #[tokio::test]
4224 async fn documented_shape_fixture_not_live_tested_entra_id_v2() {
4225 let issuer = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0";
4228 let t = accepts(
4229 |c| {
4230 c.issuer = issuer.into();
4231 c.audience = "11111111-1111-1111-1111-111111111111".into();
4232 },
4233 Algorithm::RS256,
4234 KID_A,
4235 Some("JWT"),
4236 serde_json::json!({
4237 "iss": issuer, "aud": "11111111-1111-1111-1111-111111111111",
4238 "sub": "pairwise", "oid": "o", "exp": now() + 3600,
4239 "preferred_username": "alice@example.com", "scp": "mcp.read mcp:read",
4240 }),
4241 )
4242 .await;
4243 assert!(t.has_scope("mcp:read"));
4244 }
4245
4246 #[tokio::test]
4247 async fn documented_shape_fixture_not_live_tested_auth0() {
4248 let issuer = "https://tenant.example.auth0.com/";
4252 for typ in ["JWT", "at+jwt"] {
4253 let t = accepts(
4254 |c| {
4255 c.issuer = issuer.into();
4256 c.audience = "https://kb.example.com/mcp".into();
4257 },
4258 Algorithm::RS256,
4259 KID_A,
4260 Some(typ),
4261 serde_json::json!({
4262 "iss": issuer,
4263 "aud": ["https://kb.example.com/mcp", "https://tenant.example.auth0.com/userinfo"],
4264 "azp": "client", "sub": "auth0|1", "exp": now() + 3600,
4265 "scope": "openid mcp:read",
4266 }),
4267 )
4268 .await;
4269 assert!(t.has_scope("mcp:read"));
4270 }
4271 }
4272
4273 #[tokio::test]
4274 async fn documented_shape_fixture_not_live_tested_ory_hydra_jwt_strategy() {
4275 let issuer = "https://hydra.example.com/";
4278 for scp in [
4279 serde_json::json!(["mcp:read"]),
4280 serde_json::json!("offline mcp:read"),
4281 ] {
4282 let t = accepts(
4283 |c| {
4284 c.issuer = issuer.into();
4285 c.audience = "https://kb.example.com/mcp".into();
4286 },
4287 Algorithm::RS256,
4288 KID_A,
4289 Some("JWT"),
4290 serde_json::json!({
4291 "iss": issuer, "aud": ["https://kb.example.com/mcp"], "sub": "u",
4292 "client_id": "c", "exp": now() + 3600, "scp": scp, "ext": {},
4293 }),
4294 )
4295 .await;
4296 assert!(t.has_scope("mcp:read"));
4297 }
4298 }
4299
4300 #[tokio::test]
4301 async fn documented_shape_fixture_not_live_tested_logto_resource_indicator() {
4302 let issuer = "https://logto.example.com/oidc";
4305 let t = accepts(
4306 |c| {
4307 c.issuer = issuer.into();
4308 c.audience = "https://kb.example.com/mcp".into();
4309 },
4310 Algorithm::ES256,
4311 KID_EC,
4312 None,
4313 serde_json::json!({
4314 "iss": issuer, "aud": "https://kb.example.com/mcp", "sub": "u",
4315 "client_id": "c", "exp": now() + 3600, "scope": "mcp:read",
4316 }),
4317 )
4318 .await;
4319 assert!(t.has_scope("mcp:read"));
4320 }
4321
4322 #[tokio::test]
4323 async fn documented_shape_fixture_not_live_tested_casdoor_jwt_standard() {
4324 let issuer = "https://casdoor.example.com";
4328 let t = accepts(
4329 |c| {
4330 c.issuer = issuer.into();
4331 c.audience = "example-client-id".into();
4332 },
4333 Algorithm::RS256,
4334 KID_A,
4335 Some("JWT"),
4336 serde_json::json!({
4337 "iss": issuer, "aud": ["example-client-id"], "sub": "u",
4338 "exp": now() + 3600, "preferred_username": "alice",
4339 "scope": "openid mcp:read",
4340 }),
4341 )
4342 .await;
4343 assert_eq!(t.principal.as_deref(), Some("alice"));
4344 }
4345
4346 #[tokio::test]
4347 async fn documented_shape_fixture_not_live_tested_rauthy_eddsa_at_jwt() {
4348 let issuer = "https://rauthy.example.com/auth/v1";
4351 let t = accepts(
4352 |c| {
4353 c.issuer = issuer.into();
4354 c.audience = "example-client".into();
4355 c.require_at_jwt = true;
4356 },
4357 Algorithm::EdDSA,
4358 KID_ED,
4359 Some("at+jwt"),
4360 serde_json::json!({
4361 "iss": issuer, "aud": "example-client", "azp": "example-client",
4362 "sub": "user-id", "exp": now() + 1800, "scope": "openid mcp:read",
4363 }),
4364 )
4365 .await;
4366 assert_eq!(t.principal.as_deref(), Some("user-id"));
4367 }
4368
4369 #[tokio::test]
4370 async fn documented_shape_fixture_not_live_tested_dex_needs_a_group_claim_as_scope() {
4371 let issuer = "https://dex.example.com";
4375 let t = accepts(
4376 |c| {
4377 c.issuer = issuer.into();
4378 c.audience = "example-client".into();
4379 c.scope_claims = vec!["groups".into()];
4380 c.required_scopes = vec!["wiki-users".into()];
4381 },
4382 Algorithm::RS256,
4383 KID_A,
4384 None,
4385 serde_json::json!({
4386 "iss": issuer, "aud": "example-client", "sub": "u",
4387 "exp": now() + 3600, "email": "a@example.com",
4388 "groups": ["wiki-users", "admins"],
4389 }),
4390 )
4391 .await;
4392 assert!(t.has_scope("wiki-users"));
4393 }
4394
4395 #[tokio::test]
4396 async fn documented_shape_fixture_not_live_tested_zitadel_jwt_mode() {
4397 let issuer = "https://zitadel.example.com";
4402 let t = accepts(
4403 |c| {
4404 c.issuer = issuer.into();
4405 c.audience = "123456789012345678".into();
4406 },
4407 Algorithm::RS256,
4408 KID_A,
4409 None,
4410 serde_json::json!({
4411 "iss": issuer,
4412 "aud": ["234567890123456789@wiki", "123456789012345678"],
4413 "client_id": "234567890123456789@wiki", "sub": "u",
4414 "exp": now() + 3600, "scope": "openid mcp:read",
4415 }),
4416 )
4417 .await;
4418 assert!(t.has_scope("mcp:read"));
4419 }
4420
4421 fn at(secs: u64) -> std::time::SystemTime {
4424 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)
4425 }
4426
4427 #[tokio::test]
4428 async fn issuer_expiry_and_audience_string_are_filled_from_the_token() {
4429 let exp = now() + 1800;
4430 let t = scopes_of(serde_json::json!({"scope": "mcp:read", "exp": exp}))
4431 .await
4432 .unwrap();
4433 assert_eq!(t.issuer, ISSUER);
4434 assert_eq!(t.audiences, [AUDIENCE]);
4435 assert_eq!(t.expires_at, at(exp));
4436 }
4437
4438 #[tokio::test]
4439 async fn audience_array_is_normalized_to_a_list() {
4440 let t = scopes_of(serde_json::json!({
4441 "scope": "mcp:read", "aud": ["https://other.example.test", AUDIENCE],
4442 }))
4443 .await
4444 .unwrap();
4445 assert_eq!(t.audiences, ["https://other.example.test", AUDIENCE]);
4446 }
4447
4448 #[tokio::test]
4449 async fn client_id_claim_wins_over_azp() {
4450 let t = scopes_of(serde_json::json!({
4451 "scope": "mcp:read", "client_id": "client-1", "azp": "client-2",
4452 }))
4453 .await
4454 .unwrap();
4455 assert_eq!(t.client_id.as_deref(), Some("client-1"));
4456 }
4457
4458 #[tokio::test]
4459 async fn client_id_falls_back_to_azp() {
4460 let t = scopes_of(serde_json::json!({"scope": "mcp:read", "azp": "client-2"}))
4461 .await
4462 .unwrap();
4463 assert_eq!(t.client_id.as_deref(), Some("client-2"));
4464 }
4465
4466 #[tokio::test]
4467 async fn client_id_is_none_without_client_id_or_azp() {
4468 let t = scopes_of(serde_json::json!({"scope": "mcp:read"}))
4469 .await
4470 .unwrap();
4471 assert_eq!(t.client_id, None);
4472 let t = scopes_of(serde_json::json!({"scope": "mcp:read", "client_id": "", "azp": 7}))
4474 .await
4475 .unwrap();
4476 assert_eq!(t.client_id, None);
4477 }
4478
4479 #[tokio::test]
4480 async fn issued_at_is_read_when_present_and_none_when_absent() {
4481 let iat = now() - 60;
4482 let t = scopes_of(serde_json::json!({"scope": "mcp:read", "iat": iat}))
4483 .await
4484 .unwrap();
4485 assert_eq!(t.issued_at, Some(at(iat)));
4486 let t = scopes_of(serde_json::json!({"scope": "mcp:read"}))
4487 .await
4488 .unwrap();
4489 assert_eq!(t.issued_at, None);
4490 }
4491
4492 #[tokio::test]
4493 async fn jti_is_read_when_present_and_none_when_absent() {
4494 let t = scopes_of(serde_json::json!({"scope": "mcp:read", "jti": "id-42"}))
4495 .await
4496 .unwrap();
4497 assert_eq!(t.jti.as_deref(), Some("id-42"));
4498 let t = scopes_of(serde_json::json!({"scope": "mcp:read"}))
4499 .await
4500 .unwrap();
4501 assert_eq!(t.jti, None);
4502 }
4503
4504 #[tokio::test]
4505 async fn claims_returns_custom_claims_and_claims_as_round_trips() {
4506 #[derive(serde::Deserialize, Debug, PartialEq)]
4507 struct AppClaims {
4508 sub: String,
4509 email: String,
4510 groups: Vec<String>,
4511 #[serde(default)]
4512 tenant: Option<String>,
4513 }
4514 let t = scopes_of(serde_json::json!({
4515 "scope": "mcp:read",
4516 "email": "ada@example.com",
4517 "groups": ["admins", "dev"],
4518 }))
4519 .await
4520 .unwrap();
4521 assert_eq!(t.claims()["groups"], serde_json::json!(["admins", "dev"]));
4522 assert_eq!(t.claims()["iss"], ISSUER);
4523 let typed: AppClaims = t.claims_as().unwrap();
4524 assert_eq!(
4525 typed,
4526 AppClaims {
4527 sub: "user-1".into(),
4528 email: "ada@example.com".into(),
4529 groups: vec!["admins".into(), "dev".into()],
4530 tenant: None,
4531 }
4532 );
4533 #[derive(serde::Deserialize, Debug)]
4535 struct NeedsTenant {
4536 #[allow(dead_code)]
4537 tenant: String,
4538 }
4539 assert!(t.claims_as::<NeedsTenant>().is_err());
4540 }
4541
4542 #[tokio::test]
4543 async fn debug_shows_claim_names_but_never_claim_values() {
4544 let t = scopes_of(serde_json::json!({
4545 "scope": "mcp:read", "email": "private-address@example.com",
4546 }))
4547 .await
4548 .unwrap();
4549 let shown = format!("{t:?}");
4550 assert!(shown.contains("\"email\""));
4551 assert!(!shown.contains("private-address@example.com"));
4552 }
4553
4554 #[tokio::test]
4555 async fn an_out_of_range_exp_and_iat_saturate_instead_of_reading_as_expired() {
4556 let t = scopes_of(serde_json::json!({
4558 "scope": "mcp:read",
4559 "exp": 10_000_000_000_000_000_000u64,
4560 "iat": 10_000_000_000_000_000_000u64,
4561 }))
4562 .await
4563 .unwrap();
4564 let max = at(253_402_300_799);
4565 assert_eq!(t.expires_at, max);
4566 assert_eq!(t.issued_at, Some(max));
4567 }
4568
4569 #[tokio::test]
4570 async fn fractional_exp_and_iat_are_rounded_like_jsonwebtoken_rounds_them() {
4571 let base = now() + 1000;
4574 let iat = now() - 100;
4575 let t = scopes_of(serde_json::json!({
4576 "scope": "mcp:read",
4577 "exp": base as f64 + 0.5,
4578 "iat": iat as f64 + 0.4,
4579 }))
4580 .await
4581 .unwrap();
4582 assert_eq!(t.expires_at, at(base + 1));
4583 assert_eq!(t.issued_at, Some(at(iat)));
4584 }
4585
4586 #[tokio::test]
4587 async fn a_string_iat_is_ignored_and_the_token_is_still_accepted() {
4588 let t = scopes_of(serde_json::json!({"scope": "mcp:read", "iat": "yesterday"}))
4591 .await
4592 .unwrap();
4593 assert_eq!(t.issued_at, None);
4594 }
4595
4596 fn kind_of<T: std::fmt::Debug>(r: Result<T, TokenRejection>) -> InvalidTokenKind {
4599 match r {
4600 Err(TokenRejection::Invalid(invalid)) => invalid.kind(),
4601 other => panic!("expected Invalid, got {other:?}"),
4602 }
4603 }
4604
4605 fn scoped(extra: serde_json::Value) -> serde_json::Value {
4607 let mut c = claims(serde_json::json!({"scope": "mcp:read"}));
4608 for (k, val) in extra.as_object().unwrap() {
4609 c[k] = val.clone();
4610 }
4611 c
4612 }
4613
4614 #[tokio::test]
4617 async fn every_validator_refusal_names_its_kind() {
4618 use InvalidTokenKind as K;
4619 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
4620 let v = validator_no_cooldown(&jwks.url);
4621 let good = scoped(serde_json::json!({}));
4622 let mut no_exp = good.clone();
4623 no_exp.as_object_mut().unwrap().remove("exp");
4624 let later = now() + 365 * 24 * 3600;
4625 let bad_signature_encoding = {
4626 let t = mint(KEY_A_PEM, KID_A, &good);
4627 format!("{}.@@@", &t[..t.rfind('.').unwrap()])
4628 };
4629
4630 let cases: Vec<(String, InvalidTokenKind)> = vec![
4631 ("a".repeat(MAX_TOKEN_BYTES + 1), K::TooLarge),
4632 ("not-a-jwt".into(), K::NotJwt),
4633 ("@@@.e30.sig".into(), K::MalformedHeader),
4634 (
4635 mint_raw_header(serde_json::json!({"alg": "none"}), good.clone()),
4636 K::MalformedHeader,
4637 ),
4638 (
4639 mint_raw_header(
4640 serde_json::json!({"alg": "RS256", "kid": KID_A, "crit": ["x"]}),
4641 good.clone(),
4642 ),
4643 K::CriticalHeader,
4644 ),
4645 (
4646 mint_raw_header(
4647 serde_json::json!({"alg": "HS256", "kid": KID_A}),
4648 good.clone(),
4649 ),
4650 K::AlgorithmNotAllowed,
4651 ),
4652 (
4653 mint_with(
4654 crate::Algorithm::RS256,
4655 Some(KID_A),
4656 Some("dpop+jwt"),
4657 &good,
4658 ),
4659 K::TypeNotAllowed,
4660 ),
4661 (mint(KEY_A_PEM, "no-such-kid", &good), K::KeyNotFound),
4662 (
4663 mint(KEY_A_PEM, KID_A, &"a JSON string, not an object"),
4664 K::MalformedToken,
4665 ),
4666 (bad_signature_encoding, K::MalformedToken),
4667 (mint(KEY_B_PEM, KID_A, &good), K::BadSignature),
4668 (
4669 mint(
4670 KEY_A_PEM,
4671 KID_A,
4672 &scoped(serde_json::json!({"exp": now() - 3600})),
4673 ),
4674 K::Expired,
4675 ),
4676 (
4677 mint(KEY_A_PEM, KID_A, &scoped(serde_json::json!({"nbf": later}))),
4678 K::NotYetValid,
4679 ),
4680 (
4681 mint(
4682 KEY_A_PEM,
4683 KID_A,
4684 &scoped(serde_json::json!({"iss": "https://other.example.test/"})),
4685 ),
4686 K::WrongIssuer,
4687 ),
4688 (
4689 mint(
4690 KEY_A_PEM,
4691 KID_A,
4692 &scoped(serde_json::json!({"iss": [ISSUER]})),
4693 ),
4694 K::WrongIssuer,
4695 ),
4696 (
4697 mint(
4698 KEY_A_PEM,
4699 KID_A,
4700 &scoped(serde_json::json!({"aud": "someone-else"})),
4701 ),
4702 K::WrongAudience,
4703 ),
4704 (mint(KEY_A_PEM, KID_A, &no_exp), K::MissingClaim),
4705 (
4706 mint(
4707 KEY_A_PEM,
4708 KID_A,
4709 &scoped(serde_json::json!({"nbf": "later"})),
4710 ),
4711 K::MalformedClaim,
4712 ),
4713 (
4714 mint(
4715 KEY_A_PEM,
4716 KID_A,
4717 &scoped(serde_json::json!({"cnf": {"jkt": "x"}})),
4718 ),
4719 K::SenderConstrained,
4720 ),
4721 ];
4722 for (token, expected) in cases {
4723 let got = kind_of(v.validate(&token).await);
4724 assert_eq!(got, expected, "token {}", for_log(&token));
4725 }
4726 let expired = mint(
4729 KEY_A_PEM,
4730 KID_A,
4731 &scoped(serde_json::json!({"exp": now() - 3600})),
4732 );
4733 let CachedAttempt::Decided(r) = v.validate_cached(&expired).await else {
4734 panic!("the key is cached by now");
4735 };
4736 assert_eq!(kind_of(r), K::Expired);
4737 assert_eq!(
4738 kind_of(crate::authenticate([expired.as_str(), "x"], None, Some(&v)).await),
4739 K::Expired
4740 );
4741 }
4742
4743 #[tokio::test]
4746 async fn an_unreachable_key_set_is_key_set_unavailable() {
4747 let v = validator("http://127.0.0.1:1/jwks");
4751 for attempt in 0..3 {
4752 assert_eq!(
4753 kind_of(v.validate(&valid_token()).await),
4754 InvalidTokenKind::KeySetUnavailable,
4755 "attempt {attempt}"
4756 );
4757 }
4758 let jwks = spawn_jwks_server("500 Internal Server Error", "{}".into()).await;
4759 let v = validator(&jwks.url);
4760 assert_eq!(
4761 kind_of(v.validate(&valid_token()).await),
4762 InvalidTokenKind::KeySetUnavailable
4763 );
4764 }
4765
4766 #[tokio::test]
4769 async fn an_unknown_kid_in_the_cooldown_is_key_not_found() {
4770 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
4771 let v = validator(&jwks.url);
4772 let token = mint(
4773 KEY_A_PEM,
4774 "rotated-key",
4775 &claims(serde_json::json!({"scope": "mcp:read"})),
4776 );
4777 for _ in 0..2 {
4778 assert_eq!(
4779 kind_of(v.validate(&token).await),
4780 InvalidTokenKind::KeyNotFound
4781 );
4782 }
4783 assert_eq!(jwks.hits.load(Ordering::SeqCst), 1);
4784 }
4785
4786 async fn policy_validator(
4790 adjust: impl FnOnce(&mut ResolvedOAuthConfig),
4791 ) -> (FakeJwksServer, OAuthValidator) {
4792 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
4793 let mut cfg = oauth_config(&jwks.url);
4794 adjust(&mut cfg);
4795 let v = validator_with(cfg);
4796 (jwks, v)
4797 }
4798
4799 async fn outcome(v: &OAuthValidator, extra: serde_json::Value) -> Result<(), InvalidTokenKind> {
4800 match v.validate(&mint(KEY_A_PEM, KID_A, &scoped(extra))).await {
4801 Ok(_) => Ok(()),
4802 Err(TokenRejection::Invalid(invalid)) => Err(invalid.kind()),
4803 Err(other) => panic!("expected Ok or Invalid, got {other:?}"),
4804 }
4805 }
4806
4807 #[tokio::test]
4808 async fn allowed_client_ids_match_client_id_then_azp() {
4809 use InvalidTokenKind as K;
4810 let (_jwks, v) = policy_validator(|c| {
4811 c.allowed_client_ids = vec!["client-a".into(), "client-b".into()];
4812 })
4813 .await;
4814 let cases = [
4815 (serde_json::json!({"client_id": "client-a"}), Ok(())),
4816 (serde_json::json!({"azp": "client-b"}), Ok(())),
4817 (
4820 serde_json::json!({"client_id": "", "azp": "client-a"}),
4821 Err(K::ClientNotAllowed),
4822 ),
4823 (
4824 serde_json::json!({"client_id": 7, "azp": "client-a"}),
4825 Err(K::ClientNotAllowed),
4826 ),
4827 (
4828 serde_json::json!({"client_id": null, "azp": "client-a"}),
4829 Err(K::ClientNotAllowed),
4830 ),
4831 (
4832 serde_json::json!({"client_id": ["client-a"], "azp": "client-a"}),
4833 Err(K::ClientNotAllowed),
4834 ),
4835 (
4838 serde_json::json!({"client_id": "client-x", "azp": "client-a"}),
4839 Err(K::ClientNotAllowed),
4840 ),
4841 (
4842 serde_json::json!({"azp": "client-x"}),
4843 Err(K::ClientNotAllowed),
4844 ),
4845 (
4847 serde_json::json!({"client_id": "CLIENT-A"}),
4848 Err(K::ClientNotAllowed),
4849 ),
4850 (serde_json::json!({}), Err(K::ClientNotAllowed)),
4852 (
4853 serde_json::json!({"client_id": 7}),
4854 Err(K::ClientNotAllowed),
4855 ),
4856 ];
4857 for (extra, want) in cases {
4858 assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
4859 }
4860 let unscoped = mint(
4863 KEY_A_PEM,
4864 KID_A,
4865 &claims(serde_json::json!({"client_id": "client-x"})),
4866 );
4867 assert_eq!(kind_of(v.validate(&unscoped).await), K::ClientNotAllowed);
4868 let unscoped = mint(
4870 KEY_A_PEM,
4871 KID_A,
4872 &claims(serde_json::json!({"client_id": "client-a"})),
4873 );
4874 assert_eq!(
4875 v.validate(&unscoped).await.unwrap_err(),
4876 TokenRejection::InsufficientScope
4877 );
4878 let t = v
4880 .validate(&mint(
4881 KEY_A_PEM,
4882 KID_A,
4883 &scoped(serde_json::json!({"azp": "client-b"})),
4884 ))
4885 .await
4886 .unwrap();
4887 assert_eq!(t.client_id.as_deref(), Some("client-b"));
4888 let (_jwks, open) = policy_validator(|_| {}).await;
4891 let t = open
4892 .validate(&mint(
4893 KEY_A_PEM,
4894 KID_A,
4895 &scoped(serde_json::json!({"client_id": "", "azp": "client-b"})),
4896 ))
4897 .await
4898 .unwrap();
4899 assert_eq!(t.client_id.as_deref(), Some("client-b"));
4900 }
4901
4902 #[tokio::test]
4903 async fn max_token_age_bounds_iat_with_the_leeway() {
4904 use InvalidTokenKind as K;
4905 let (_jwks, v) = policy_validator(|c| {
4906 c.max_token_age_secs = Some(600);
4907 c.leeway_secs = 60;
4908 })
4909 .await;
4910 let t = now();
4911 let cases = [
4912 (serde_json::json!({"iat": t}), Ok(())),
4913 (serde_json::json!({"iat": t - 600}), Ok(())),
4914 (serde_json::json!({"iat": t - 600 - 50}), Ok(())),
4916 (
4917 serde_json::json!({"iat": t - 600 - 70}),
4918 Err(K::TokenTooOld),
4919 ),
4920 (serde_json::json!({"iat": 0}), Err(K::TokenTooOld)),
4921 (serde_json::json!({"iat": t as f64 - 0.4}), Ok(())),
4923 (serde_json::json!({"iat": t + 50}), Ok(())),
4926 (serde_json::json!({"iat": t + 3600}), Err(K::NotYetValid)),
4927 (serde_json::json!({"iat": u64::MAX}), Err(K::NotYetValid)),
4928 (serde_json::json!({}), Err(K::MissingClaim)),
4930 (
4931 serde_json::json!({"iat": t.to_string()}),
4932 Err(K::MalformedClaim),
4933 ),
4934 (serde_json::json!({"iat": -1}), Err(K::MalformedClaim)),
4935 (serde_json::json!({"iat": null}), Err(K::MalformedClaim)),
4936 ];
4937 for (extra, want) in cases {
4938 assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
4939 }
4940 }
4941
4942 #[tokio::test]
4943 async fn required_claims_match_exactly_or_by_array_membership() {
4944 use InvalidTokenKind as K;
4945 let (_jwks, v) = policy_validator(|c| {
4946 c.required_claims = [
4947 ("tid".to_string(), serde_json::json!("tenant-1")),
4948 ("groups".to_string(), serde_json::json!("api-users")),
4949 ]
4950 .into_iter()
4951 .collect();
4952 })
4953 .await;
4954 let ok = serde_json::json!({"tid": "tenant-1", "groups": ["x", "api-users"]});
4955 let cases = [
4956 (ok.clone(), Ok(())),
4957 (
4958 serde_json::json!({"tid": "tenant-1", "groups": "api-users"}),
4959 Ok(()),
4960 ),
4961 (
4962 serde_json::json!({"tid": ["tenant-1"], "groups": "api-users"}),
4963 Ok(()),
4964 ),
4965 (
4966 serde_json::json!({"tid": "tenant-2", "groups": "api-users"}),
4967 Err(K::ClaimMismatch),
4968 ),
4969 (
4970 serde_json::json!({"tid": "tenant-1", "groups": ["admins"]}),
4971 Err(K::ClaimMismatch),
4972 ),
4973 (
4974 serde_json::json!({"tid": "tenant-1", "groups": []}),
4975 Err(K::ClaimMismatch),
4976 ),
4977 (
4978 serde_json::json!({"tid": "tenant-1", "groups": null}),
4979 Err(K::ClaimMismatch),
4980 ),
4981 (
4982 serde_json::json!({"tid": "tenant-1", "groups": {"api-users": true}}),
4983 Err(K::ClaimMismatch),
4984 ),
4985 (
4987 serde_json::json!({"tid": "tenant-1", "groups": [["api-users"]]}),
4988 Err(K::ClaimMismatch),
4989 ),
4990 (
4991 serde_json::json!({"groups": "api-users"}),
4992 Err(K::MissingClaim),
4993 ),
4994 (serde_json::json!({"tid": "tenant-1"}), Err(K::MissingClaim)),
4995 ];
4996 for (extra, want) in cases {
4997 assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
4998 }
4999
5000 let (_jwks, v) = policy_validator(|c| {
5002 c.required_claims = [
5003 ("level".to_string(), serde_json::json!(3)),
5004 ("mfa".to_string(), serde_json::json!(true)),
5005 ]
5006 .into_iter()
5007 .collect();
5008 })
5009 .await;
5010 let cases = [
5011 (serde_json::json!({"level": 3, "mfa": true}), Ok(())),
5012 (serde_json::json!({"level": [1, 3], "mfa": true}), Ok(())),
5013 (
5014 serde_json::json!({"level": "3", "mfa": true}),
5015 Err(K::ClaimMismatch),
5016 ),
5017 (
5018 serde_json::json!({"level": 3, "mfa": "true"}),
5019 Err(K::ClaimMismatch),
5020 ),
5021 (
5022 serde_json::json!({"level": 3, "mfa": false}),
5023 Err(K::ClaimMismatch),
5024 ),
5025 ];
5026 for (extra, want) in cases {
5027 assert_eq!(outcome(&v, extra.clone()).await, want, "{extra}");
5028 }
5029 }
5030
5031 #[tokio::test]
5036 async fn the_claim_policy_is_off_by_default_and_never_runs_before_the_signature() {
5037 let (_jwks, v) = policy_validator(|_| {}).await;
5038 assert!(v.config().allowed_client_ids.is_empty());
5039 assert_eq!(v.config().max_token_age_secs, None);
5040 assert!(v.config().required_claims.is_empty());
5041 let bare = claims(serde_json::json!({"scope": "mcp:read"}));
5042 for claim in ["client_id", "azp", "iat"] {
5043 assert!(bare.get(claim).is_none());
5044 }
5045 assert!(v.validate(&mint(KEY_A_PEM, KID_A, &bare)).await.is_ok());
5046
5047 let (_jwks, v) = policy_validator(|c| {
5048 c.allowed_client_ids = vec!["client-a".into()];
5049 c.max_token_age_secs = Some(600);
5050 c.required_claims = [("tid".to_string(), serde_json::json!("t"))]
5051 .into_iter()
5052 .collect();
5053 })
5054 .await;
5055 let wanted = scoped(serde_json::json!({"client_id": "client-a", "iat": now(), "tid": "t"}));
5057 assert_eq!(
5058 kind_of(v.validate(&mint(KEY_B_PEM, KID_A, &wanted)).await),
5059 InvalidTokenKind::BadSignature
5060 );
5061 assert!(v.validate(&mint(KEY_A_PEM, KID_A, &wanted)).await.is_ok());
5062 let mut expired = wanted.clone();
5064 expired["exp"] = serde_json::json!(now() - 3600);
5065 assert_eq!(
5066 kind_of(v.validate(&mint(KEY_A_PEM, KID_A, &expired)).await),
5067 InvalidTokenKind::Expired
5068 );
5069 let mut both = wanted.clone();
5071 both["client_id"] = serde_json::json!("client-x");
5072 both.as_object_mut().unwrap().remove("iat");
5073 assert_eq!(
5074 kind_of(v.validate(&mint(KEY_A_PEM, KID_A, &both)).await),
5075 InvalidTokenKind::ClientNotAllowed
5076 );
5077 let mut age_and_claim = wanted.clone();
5078 age_and_claim.as_object_mut().unwrap().remove("iat");
5079 age_and_claim["tid"] = serde_json::json!("other");
5080 assert_eq!(
5081 kind_of(v.validate(&mint(KEY_A_PEM, KID_A, &age_and_claim)).await),
5082 InvalidTokenKind::MissingClaim
5083 );
5084 }
5085
5086 #[tokio::test]
5092 async fn documented_shape_fixture_not_live_tested_shared_audience_client_restriction() {
5093 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5094
5095 let auth0 = "https://tenant.example.auth0.com/";
5096 let mut cfg = oauth_config(&jwks.url);
5097 cfg.issuer = auth0.into();
5098 cfg.audience = "https://kb.example.com/mcp".into();
5099 cfg.allowed_client_ids = vec!["client".into()];
5100 let v = validator_with(cfg);
5101 let auth0_token = |azp: &str| {
5102 mint(
5103 KEY_A_PEM,
5104 KID_A,
5105 &serde_json::json!({
5106 "iss": auth0,
5107 "aud": ["https://kb.example.com/mcp", "https://tenant.example.auth0.com/userinfo"],
5108 "azp": azp, "sub": "auth0|1", "exp": now() + 3600,
5109 "scope": "openid mcp:read",
5110 }),
5111 )
5112 };
5113 assert!(v.validate(&auth0_token("client")).await.is_ok());
5114 assert_eq!(
5115 kind_of(v.validate(&auth0_token("another-client")).await),
5116 InvalidTokenKind::ClientNotAllowed
5117 );
5118
5119 let okta = "https://example.okta.com/oauth2/default";
5120 let mut cfg = oauth_config(&jwks.url);
5121 cfg.issuer = okta.into();
5122 cfg.audience = "api://default".into();
5123 cfg.required_claims = [("cid".to_string(), serde_json::json!("client"))]
5124 .into_iter()
5125 .collect();
5126 let v = validator_with(cfg);
5127 let okta_token = |cid: &str| {
5128 mint(
5129 KEY_A_PEM,
5130 KID_A,
5131 &serde_json::json!({
5132 "iss": okta, "aud": "api://default", "cid": cid, "sub": "a@example.com",
5133 "exp": now() + 3600, "scp": ["openid", "mcp:read"],
5134 }),
5135 )
5136 };
5137 assert!(v.validate(&okta_token("client")).await.is_ok());
5138 assert_eq!(
5139 kind_of(v.validate(&okta_token("another-client")).await),
5140 InvalidTokenKind::ClaimMismatch
5141 );
5142 }
5143
5144 #[tokio::test]
5149 async fn the_cooldown_kind_follows_the_last_refresh() {
5150 let server = spawn_http_server(
5151 HashMap::from([("/jwks".to_string(), ("200 OK", jwks_body()))]),
5152 None,
5153 )
5154 .await;
5155 let v = validator(&format!("{}/jwks", server.base)); v.refresh_now().await.unwrap();
5157 let unknown = mint(
5158 KEY_A_PEM,
5159 "rotated-key",
5160 &claims(serde_json::json!({"scope": "mcp:read"})),
5161 );
5162 assert_eq!(
5163 kind_of(v.validate(&unknown).await),
5164 InvalidTokenKind::KeyNotFound
5165 );
5166 server.routes.lock().unwrap().insert(
5168 "/jwks".to_string(),
5169 ("500 Internal Server Error", "{}".to_string()),
5170 );
5171 assert!(v.refresh_now().await.is_err());
5172 for _ in 0..2 {
5173 assert_eq!(
5174 kind_of(v.validate(&unknown).await),
5175 InvalidTokenKind::KeySetUnavailable
5176 );
5177 }
5178 assert!(v.validate(&valid_token()).await.is_ok());
5180 server
5182 .routes
5183 .lock()
5184 .unwrap()
5185 .insert("/jwks".to_string(), ("200 OK", jwks_body()));
5186 v.refresh_now().await.unwrap();
5187 assert_eq!(
5188 kind_of(v.validate(&unknown).await),
5189 InvalidTokenKind::KeyNotFound
5190 );
5191 }
5192
5193 #[tokio::test]
5196 async fn an_unreadable_required_claim_is_malformed_not_missing() {
5197 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5198 let v = validator(&jwks.url);
5199 for extra in [
5200 serde_json::json!({"exp": "tomorrow"}),
5201 serde_json::json!({"exp": null}),
5202 serde_json::json!({"iss": 7}),
5203 serde_json::json!({"aud": 7}),
5204 serde_json::json!({"aud": {"id": AUDIENCE}}),
5205 ] {
5206 let token = mint(KEY_A_PEM, KID_A, &scoped(extra.clone()));
5207 assert_eq!(
5208 kind_of(v.validate(&token).await),
5209 InvalidTokenKind::MalformedClaim,
5210 "{extra}"
5211 );
5212 }
5213 for name in ["exp", "iss", "aud"] {
5214 let mut c = scoped(serde_json::json!({}));
5215 c.as_object_mut().unwrap().remove(name);
5216 let token = mint(KEY_A_PEM, KID_A, &c);
5217 assert_eq!(
5218 kind_of(v.validate(&token).await),
5219 InvalidTokenKind::MissingClaim,
5220 "{name}"
5221 );
5222 }
5223 }
5224
5225 #[test]
5226 fn required_claims_on_scope_or_client_claims_are_warned_about_not_refused() {
5227 let mut cfg = oauth_config("http://127.0.0.1:1/jwks");
5228 cfg.scope_claims.push("roles".into());
5229 cfg.required_claims = ["scope", "scp", "roles", "azp", "client_id", "tid"]
5230 .into_iter()
5231 .map(|n| (n.to_string(), serde_json::json!("x")))
5232 .collect();
5233 let named: Vec<&str> = required_claim_footguns(&cfg)
5234 .into_iter()
5235 .map(|(n, _)| n)
5236 .collect();
5237 assert_eq!(named, ["azp", "client_id", "roles", "scope", "scp"]);
5238 assert!(OAuthValidator::new(&cfg).is_ok());
5240 cfg.required_claims = [("tid".to_string(), serde_json::json!("x"))]
5241 .into_iter()
5242 .collect();
5243 assert!(required_claim_footguns(&cfg).is_empty());
5244 }
5245
5246 async fn raw_http_server(
5252 chunks: impl Fn() -> Vec<Vec<u8>> + Send + Sync + 'static,
5253 pause: Duration,
5254 ) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
5255 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5256 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5257 let addr = listener.local_addr().unwrap();
5258 let written = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5259 let counter = Arc::clone(&written);
5260 let chunks = Arc::new(chunks);
5261 tokio::spawn(async move {
5262 while let Ok((mut sock, _)) = listener.accept().await {
5263 let counter = Arc::clone(&counter);
5264 let chunks = Arc::clone(&chunks);
5265 tokio::spawn(async move {
5266 let mut request = vec![0u8; 4096];
5267 let _ = sock.read(&mut request).await;
5268 for chunk in chunks() {
5269 if sock.write_all(&chunk).await.is_err() {
5270 return;
5271 }
5272 counter.fetch_add(chunk.len(), Ordering::SeqCst);
5273 if !pause.is_zero() {
5274 tokio::time::sleep(pause).await;
5275 }
5276 }
5277 });
5278 }
5279 });
5280 (format!("http://{addr}/jwks"), written)
5281 }
5282
5283 #[tokio::test]
5287 async fn the_byte_cap_is_enforced_while_streaming_a_chunked_body() {
5288 let (url, written) = raw_http_server(
5289 || {
5290 let mut out = vec![
5291 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
5292 Transfer-Encoding: chunked\r\n\r\n"
5293 .to_vec(),
5294 ];
5295 let chunk = "x".repeat(32 * 1024);
5296 for _ in 0..1000 {
5297 out.push(format!("{:x}\r\n{chunk}\r\n", chunk.len()).into_bytes());
5298 }
5299 out
5300 },
5301 Duration::from_millis(1),
5302 )
5303 .await;
5304 let v = validator_no_cooldown(&url);
5305 let err = tokio::time::timeout(Duration::from_secs(10), v.refresh_now())
5306 .await
5307 .expect("the fetch stops at the cap")
5308 .unwrap_err();
5309 assert_eq!(err.kind(), RefreshErrorKind::Fetch);
5310 assert!(err.to_string().contains("response exceeds the"), "{err}");
5311 tokio::time::sleep(Duration::from_millis(200)).await;
5312 let sent = written.load(Ordering::SeqCst);
5313 assert!(
5314 sent < 2 * MAX_FETCH_BYTES + 64 * 1024,
5315 "the server wrote {sent} bytes before the client went away"
5316 );
5317
5318 let (url, _) = raw_http_server(
5320 || {
5321 let body = jwks_body();
5322 vec![
5323 format!(
5324 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n{body}\r\n0\r\n\r\n",
5325 body.len()
5326 )
5327 .into_bytes(),
5328 ]
5329 },
5330 Duration::ZERO,
5331 )
5332 .await;
5333 assert_eq!(validator_no_cooldown(&url).refresh_now().await.unwrap(), 1);
5334 }
5335
5336 #[tokio::test]
5340 async fn a_content_length_over_the_cap_is_refused_before_reading() {
5341 let (url, _) = raw_http_server(
5342 || {
5343 vec![
5344 format!(
5345 "HTTP/1.1 200 OK\r\nContent-Length: 99999999\r\n\r\n{}",
5346 jwks_body()
5347 )
5348 .into_bytes(),
5349 ]
5350 },
5351 Duration::ZERO,
5352 )
5353 .await;
5354 let err = validator_no_cooldown(&url).refresh_now().await.unwrap_err();
5355 assert_eq!(err.kind(), RefreshErrorKind::Fetch);
5356 assert!(
5357 err.to_string()
5358 .contains(&format!("over the {MAX_FETCH_BYTES}-byte cap")),
5359 "{err}"
5360 );
5361 let mut body = jwks_body();
5363 let pad = MAX_FETCH_BYTES - body.len() - ",\"p\":\"\"".len();
5364 body.insert_str(body.len() - 1, &format!(",\"p\":\"{}\"", "y".repeat(pad)));
5365 assert_eq!(body.len(), MAX_FETCH_BYTES);
5366 let jwks = spawn_jwks_server("200 OK", body).await;
5367 assert_eq!(
5368 validator_no_cooldown(&jwks.url)
5369 .refresh_now()
5370 .await
5371 .unwrap(),
5372 1
5373 );
5374 }
5375
5376 #[tokio::test]
5379 async fn a_fetched_key_set_keeps_only_the_first_64_keys() {
5380 let keys: Vec<serde_json::Value> = (0..=crate::jwks::MAX_JWKS_KEYS)
5381 .map(|i| jwk_rsa_a_any_alg(&format!("k{i}")))
5382 .collect();
5383 let jwks = spawn_jwks_server("200 OK", jwks_of(&keys)).await;
5384 let v = validator_no_cooldown(&jwks.url);
5385 assert_eq!(v.refresh_now().await.unwrap(), crate::jwks::MAX_JWKS_KEYS);
5386 let body = claims(serde_json::json!({"scope": "mcp:read"}));
5387 assert!(v.validate(&mint(KEY_A_PEM, "k63", &body)).await.is_ok());
5388 match v.validate(&mint(KEY_A_PEM, "k64", &body)).await {
5389 Err(TokenRejection::Invalid(invalid)) => {
5390 assert_eq!(invalid.kind(), InvalidTokenKind::KeyNotFound)
5391 }
5392 other => panic!("{other:?}"),
5393 }
5394 }
5395
5396 #[tokio::test]
5399 async fn an_explicit_proxy_is_bypassed_for_a_redirect_hop_to_loopback() {
5400 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5401 let target = "http://jwks.example.test/jwks";
5402 for hop in [
5403 jwks.url.clone(),
5404 jwks.url.replace("127.0.0.1", "localhost"),
5405 jwks.url.replace("127.0.0.1", "127.0.0.9"),
5406 ] {
5407 let status: &'static str =
5411 Box::leak(format!("302 Found\r\nLocation: {hop}").into_boxed_str());
5412 let proxy = spawn_http_server(
5413 HashMap::from([(target.to_string(), (status, String::new()))]),
5414 None,
5415 )
5416 .await;
5417 let mut cfg = oauth_config(target);
5418 cfg.allow_insecure_http = true;
5419 let v = OAuthValidator::builder(&cfg)
5420 .proxy(&proxy.base)
5421 .build()
5422 .unwrap();
5423 let result = v.refresh_now().await;
5424 assert_eq!(proxy.hits.load(Ordering::SeqCst), 1, "{hop}: proxy hits");
5425 if hop.contains("127.0.0.9") {
5426 assert!(result.is_err(), "{hop}");
5428 } else {
5429 assert_eq!(result.unwrap(), 1, "{hop}");
5430 }
5431 }
5432 }
5433
5434 #[tokio::test]
5437 async fn a_signed_token_over_the_size_cap_is_too_large_without_a_fetch() {
5438 let jwks = spawn_jwks_server("200 OK", jwks_body()).await;
5439 let v = validator(&jwks.url);
5440 let token = |kid_len: usize, pad: usize| {
5444 let body = claims(serde_json::json!({"scope": "mcp:read", "pad": "p".repeat(pad)}));
5445 mint(KEY_A_PEM, &"k".repeat(kid_len), &body)
5446 };
5447 let (mut kid_len, mut too_long) = (0, MAX_TOKEN_BYTES);
5448 while too_long - kid_len > 1 {
5449 let mid = (kid_len + too_long) / 2;
5450 if token(mid, 0).len() <= MAX_TOKEN_BYTES {
5451 kid_len = mid;
5452 } else {
5453 too_long = mid;
5454 }
5455 }
5456 let exactly = |len: usize| {
5457 (kid_len.saturating_sub(8)..=kid_len)
5458 .flat_map(|k| (0..8).map(move |p| (k, p)))
5459 .map(|(k, p)| token(k, p))
5460 .find(|t| t.len() == len)
5461 .expect("a token of exactly that length")
5462 };
5463 let at_cap = exactly(MAX_TOKEN_BYTES);
5464 let over = exactly(MAX_TOKEN_BYTES + 1);
5465 assert_eq!(over.len(), MAX_TOKEN_BYTES + 1);
5466 match v.validate(&over).await {
5467 Err(TokenRejection::Invalid(invalid)) => {
5468 assert_eq!(invalid.kind(), InvalidTokenKind::TooLarge)
5469 }
5470 other => panic!("{other:?}"),
5471 }
5472 assert_eq!(jwks.hits.load(Ordering::SeqCst), 0, "no key fetch");
5473 match v.validate(&at_cap).await {
5475 Err(TokenRejection::Invalid(invalid)) => {
5476 assert_eq!(invalid.kind(), InvalidTokenKind::KeyNotFound)
5477 }
5478 other => panic!("{other:?}"),
5479 }
5480 }
5481
5482 #[tokio::test]
5486 async fn held_keys_survive_a_parse_or_discovery_failure() {
5487 let server = spawn_http_server(HashMap::new(), None).await;
5488 let set = |path: &str, status: &'static str, body: String| {
5489 server
5490 .routes
5491 .lock()
5492 .unwrap()
5493 .insert(path.to_string(), (status, body));
5494 };
5495 set("/jwks", "200 OK", jwks_body());
5496 let v = validator_no_cooldown(&server.url);
5497 assert!(v.validate(&valid_token()).await.is_ok());
5498 for (body, kind) in [
5499 ("not json".to_string(), RefreshErrorKind::Parse),
5500 ("{}".to_string(), RefreshErrorKind::Parse),
5501 (
5502 jwks_of(&[serde_json::json!({"kty": "oct", "k": "c2VjcmV0"})]),
5503 RefreshErrorKind::NoUsableKeys,
5504 ),
5505 ] {
5506 set("/jwks", "200 OK", body.clone());
5507 assert_eq!(v.refresh_now().await.unwrap_err().kind(), kind, "{body}");
5508 assert_eq!(v.key_set_status().keys, 1, "{body}");
5509 assert!(v.validate(&valid_token()).await.is_ok(), "{body}");
5510 }
5511
5512 let issuer = format!("{}/app/", server.base);
5516 const METADATA: &str = "/app/.well-known/openid-configuration";
5517 set(
5518 METADATA,
5519 "200 OK",
5520 serde_json::json!({"issuer": issuer, "jwks_uri": format!("{}/keys", server.base)})
5521 .to_string(),
5522 );
5523 set("/keys", "200 OK", jwks_body());
5524 let mut cfg = oauth_config("");
5525 cfg.issuer = issuer.clone();
5526 let v = OAuthValidator::build(&cfg, Duration::ZERO).unwrap();
5527 let token = mint(
5528 KEY_A_PEM,
5529 KID_A,
5530 &claims(serde_json::json!({"iss": issuer, "scope": "mcp:read"})),
5531 );
5532 assert_eq!(v.refresh_now().await.unwrap(), 1);
5533 assert!(v.validate(&token).await.is_ok());
5534 set(METADATA, "404 Not Found", "{}".into());
5535 set("/keys", "404 Not Found", "{}".into());
5536 assert_eq!(
5537 v.refresh_now().await.unwrap_err().kind(),
5538 RefreshErrorKind::Fetch
5539 );
5540 assert_eq!(
5541 v.refresh_now().await.unwrap_err().kind(),
5542 RefreshErrorKind::Discovery
5543 );
5544 assert_eq!(v.key_set_status().keys, 1);
5545 assert!(v.is_ready());
5546 assert!(v.validate(&token).await.is_ok());
5549 }
5550}