1#![allow(clippy::result_large_err)]
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use jsonwebtoken::decode;
8use jsonwebtoken::decode_header;
9use jsonwebtoken::errors::ErrorKind as JwtErrorKind;
10use jsonwebtoken::jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse};
11use jsonwebtoken::{Algorithm, DecodingKey, Validation};
12use reqwest::Client;
13use serde_json::Value;
14
15use crate::cache::DocumentFetcher;
16use crate::circuit_breaker::CircuitBreaker;
17use crate::client::AuthplaneClient;
18use crate::constants::{jwt_claims, oauth_params};
19use crate::metadata::AuthorizationServerMetadata;
20use crate::oauth::introspect_token;
21use crate::prm::ProtectedResourceMetadata;
22use crate::transport::{build_basic_auth_header, is_http_success, ssrf_safe_get};
23use crate::{AuthplaneError, FetchSettings, build_prm, build_prm_url};
24use crate::{DpopRequestContext, DpopVerificationOptions};
25use crate::{VerifiedClaims, VerifierError};
26
27const DEFAULT_ALLOWED_ALGORITHMS: &[Algorithm] = &[Algorithm::RS256, Algorithm::ES256];
28const JWKS_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(30);
29
30#[derive(Debug, Clone)]
42pub struct RevocationConfig {
43 pub client_id: String,
44 pub client_secret: String,
45 pub fail_open: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct ResourceOptions {
50 pub(crate) allowed_algorithms: Vec<Algorithm>,
57 pub clock_skew_seconds: u64,
58 pub dpop_proof_max_age_seconds: u64,
62 pub revocation: Option<RevocationConfig>,
63 pub inbound_dpop: Option<crate::InboundDPoPOptions>,
78 pub(crate) resource_metadata_url: Option<String>,
87}
88
89impl Default for ResourceOptions {
90 fn default() -> Self {
91 Self {
92 allowed_algorithms: DEFAULT_ALLOWED_ALGORITHMS.to_vec(),
93 clock_skew_seconds: 30,
94 dpop_proof_max_age_seconds: 300,
95 revocation: None,
96 inbound_dpop: None,
97 resource_metadata_url: None,
98 }
99 }
100}
101
102impl ResourceOptions {
103 pub fn with_inbound_dpop(mut self, opts: crate::InboundDPoPOptions) -> Self {
120 self.inbound_dpop = Some(opts);
121 self
122 }
123
124 pub fn with_allowed_algorithms(
142 mut self,
143 algorithms: Vec<Algorithm>,
144 ) -> Result<Self, ResourceOptionsError> {
145 if algorithms.is_empty() {
146 return Err(ResourceOptionsError::EmptyAlgorithmList);
147 }
148 for alg in &algorithms {
149 if !is_supported_access_token_algorithm(*alg) {
150 return Err(ResourceOptionsError::UnsupportedAlgorithm(*alg));
151 }
152 }
153 self.allowed_algorithms = algorithms;
154 Ok(self)
155 }
156
157 pub fn allowed_algorithms(&self) -> &[Algorithm] {
159 &self.allowed_algorithms
160 }
161
162 pub fn with_resource_metadata_url(
181 mut self,
182 url: impl Into<String>,
183 ) -> Result<Self, ResourceOptionsError> {
184 let url = url.into();
185 let is_absolute = url::Url::parse(&url).is_ok_and(|parsed| parsed.has_host());
186 if !is_absolute {
187 return Err(ResourceOptionsError::InvalidResourceMetadataUrl(url));
188 }
189 if url
190 .chars()
191 .any(|c| c.is_ascii_whitespace() || c.is_ascii_control() || c == '"' || c == '\\')
192 {
193 return Err(ResourceOptionsError::InvalidResourceMetadataUrl(url));
194 }
195 self.resource_metadata_url = Some(url);
196 Ok(self)
197 }
198
199 pub fn resource_metadata_url(&self) -> Option<&str> {
201 self.resource_metadata_url.as_deref()
202 }
203}
204
205#[derive(Debug, thiserror::Error)]
208#[non_exhaustive]
209pub enum ResourceOptionsError {
210 #[error("allowed_algorithms must be non-empty; omit the setter to accept the default set")]
211 EmptyAlgorithmList,
212
213 #[error(
214 "resource_metadata_url must be an absolute URL with a host and must not contain whitespace, control characters, '\"' or '\\', got {0:?}; omit the setter to derive it from the resource identifier (RFC 9728 section 3.1)"
215 )]
216 InvalidResourceMetadataUrl(String),
217
218 #[error(
219 "access-token algorithm {0:?} is not allowed; only `RS256` and `ES256` are accepted. `none`, HMAC (`HS256`/`HS384`/`HS512`), and the additional asymmetric variants the underlying JWT crate exposes (`RS384`/`RS512`/`PS*`/`ES384`/`EdDSA`) are rejected at construction."
220 )]
221 UnsupportedAlgorithm(Algorithm),
222}
223
224#[derive(Debug, Clone)]
225pub struct AuthplaneResource {
226 issuer: String,
227 resource: String,
228 resource_metadata_url: String,
233 scopes: Vec<String>,
234 metadata: AuthorizationServerMetadata,
235 fetch_settings: FetchSettings,
236 options: ResourceOptions,
237 http: Client,
238 jwks_state: Arc<std::sync::Mutex<JwksState>>,
239 jwks_cache: Option<Arc<crate::cache::JwksCache>>,
244 metadata_binding: Option<Arc<crate::metadata_binding::MetadataBinding>>,
252 circuit_breaker: Option<Arc<CircuitBreaker>>,
260}
261
262#[derive(Debug, Clone)]
263struct JwksState {
264 keys: Vec<Jwk>,
265 loaded_at: Instant,
266}
267
268impl AuthplaneResource {
269 pub async fn create(
270 issuer: &str,
271 resource: &str,
272 scopes: &[String],
273 fetch_settings: FetchSettings,
274 ) -> Result<Self, VerifierError> {
275 Self::create_with_options(
276 issuer,
277 resource,
278 scopes,
279 fetch_settings,
280 ResourceOptions::default(),
281 )
282 .await
283 }
284
285 pub async fn create_with_options(
286 issuer: &str,
287 resource: &str,
288 scopes: &[String],
289 fetch_settings: FetchSettings,
290 options: ResourceOptions,
291 ) -> Result<Self, VerifierError> {
292 validate_resource_for_construction(resource)?;
298 let client = AuthplaneClient::create(issuer, fetch_settings.clone())
299 .await
300 .map_err(|error| VerifierError::MetadataUnavailable {
301 message: error.to_string(),
302 })?;
303 client
304 .resource_with_options(resource, scopes, options)
305 .await
306 }
307
308 #[allow(clippy::too_many_arguments)]
309 pub(crate) async fn from_parts(
310 issuer: String,
311 resource: String,
312 scopes: Vec<String>,
313 metadata: AuthorizationServerMetadata,
314 fetch_settings: FetchSettings,
315 options: ResourceOptions,
316 http: Client,
317 jwks_cache: Option<Arc<crate::cache::JwksCache>>,
318 metadata_binding: Option<Arc<crate::metadata_binding::MetadataBinding>>,
319 circuit_breaker: Option<Arc<CircuitBreaker>>,
320 ) -> Result<Self, VerifierError> {
321 validate_resource_for_construction(&resource)?;
322 validate_revocation_for_construction(&options)?;
323 let resource_metadata_url = resolve_resource_metadata_url(&resource, &options)?;
324 let initial_keys: Vec<Jwk> = if jwks_cache.is_some() {
325 Vec::new()
328 } else {
329 fetch_jwks(&metadata.jwks_uri, &fetch_settings)
330 .await
331 .map_err(|message| VerifierError::JwksUnavailable { message })?
332 };
333
334 Ok(Self {
335 issuer,
336 resource,
337 resource_metadata_url,
338 scopes,
339 metadata,
340 fetch_settings,
341 options,
342 http,
343 jwks_state: Arc::new(std::sync::Mutex::new(JwksState {
344 keys: initial_keys,
345 loaded_at: Instant::now(),
346 })),
347 jwks_cache,
348 metadata_binding,
349 circuit_breaker,
350 })
351 }
352
353 #[doc(hidden)]
364 pub fn from_prefetched_metadata(
365 issuer: &str,
366 resource: &str,
367 scopes: &[String],
368 metadata: AuthorizationServerMetadata,
369 fetch_settings: FetchSettings,
370 options: ResourceOptions,
371 jwks: JwkSet,
372 ) -> Result<Self, VerifierError> {
373 validate_resource_for_construction(resource)?;
374 validate_revocation_for_construction(&options)?;
375 let resource_metadata_url = resolve_resource_metadata_url(resource, &options)?;
376 let http = crate::transport::build_http_client(&fetch_settings).map_err(|error| {
377 VerifierError::MetadataUnavailable {
378 message: error.to_string(),
379 }
380 })?;
381 Ok(Self {
382 issuer: issuer.to_string(),
383 resource: resource.to_string(),
384 resource_metadata_url,
385 scopes: scopes.to_vec(),
386 metadata,
387 fetch_settings,
388 options,
389 http,
390 jwks_state: Arc::new(std::sync::Mutex::new(JwksState {
391 keys: jwks.keys,
392 loaded_at: Instant::now(),
393 })),
394 jwks_cache: None,
395 metadata_binding: None,
396 circuit_breaker: None,
397 })
398 }
399
400 #[cfg(test)]
401 pub(crate) fn from_metadata_and_jwks(
402 issuer: &str,
403 resource: &str,
404 scopes: &[String],
405 metadata: AuthorizationServerMetadata,
406 fetch_settings: FetchSettings,
407 options: ResourceOptions,
408 jwks: JwkSet,
409 ) -> Self {
410 Self::from_prefetched_metadata(
411 issuer,
412 resource,
413 scopes,
414 metadata,
415 fetch_settings,
416 options,
417 jwks,
418 )
419 .expect("valid fetch settings")
420 }
421
422 #[cfg(test)]
429 #[allow(clippy::too_many_arguments)]
430 pub(crate) fn from_metadata_jwks_and_breaker(
431 issuer: &str,
432 resource: &str,
433 scopes: &[String],
434 metadata: AuthorizationServerMetadata,
435 fetch_settings: FetchSettings,
436 options: ResourceOptions,
437 jwks: JwkSet,
438 circuit_breaker: Arc<CircuitBreaker>,
439 ) -> Self {
440 let http =
441 crate::transport::build_http_client(&fetch_settings).expect("valid fetch settings");
442 let resource_metadata_url =
443 resolve_resource_metadata_url(resource, &options).expect("valid resource");
444 Self {
445 issuer: issuer.to_string(),
446 resource: resource.to_string(),
447 resource_metadata_url,
448 scopes: scopes.to_vec(),
449 metadata,
450 fetch_settings,
451 options,
452 http,
453 jwks_state: Arc::new(std::sync::Mutex::new(JwksState {
454 keys: jwks.keys,
455 loaded_at: Instant::now(),
456 })),
457 jwks_cache: None,
458 metadata_binding: None,
459 circuit_breaker: Some(circuit_breaker),
460 }
461 }
462
463 pub fn resource(&self) -> &str {
464 &self.resource
465 }
466
467 pub fn issuer(&self) -> &str {
468 &self.issuer
469 }
470
471 pub fn scopes(&self) -> &[String] {
472 &self.scopes
473 }
474
475 pub fn prm_response(&self) -> ProtectedResourceMetadata {
476 let (dpop_algs, dpop_required) = match self.options.inbound_dpop.as_ref() {
483 Some(inbound) => {
484 let algs: Vec<String> = inbound
493 .resolved_allowed_proof_algorithms()
494 .iter()
495 .filter_map(prm_alg_label)
496 .collect();
497 (Some(algs), inbound.is_required())
498 }
499 None => (None, false),
500 };
501 build_prm(
502 &self.issuer,
503 &self.resource,
504 &self.scopes,
505 dpop_algs.as_deref(),
506 dpop_required,
507 )
508 }
509
510 pub fn prm_document_url(&self) -> Result<String, AuthplaneError> {
514 build_prm_url(&self.resource)
515 }
516
517 pub fn resource_metadata_url(&self) -> &str {
522 &self.resource_metadata_url
523 }
524
525 pub fn www_authenticate(&self, error: &VerifierError, realm: &str) -> String {
532 crate::www_authenticate::www_authenticate_with_resource_metadata(
533 error,
534 realm,
535 &self.resource_metadata_url,
536 )
537 }
538
539 async fn verify_claims_only(&self, token: &str) -> Result<VerifiedClaims, VerifierError> {
549 if token.trim().is_empty() {
550 return Err(VerifierError::TokenMissing);
551 }
552
553 let header = decode_header(token).map_err(|error| VerifierError::InvalidSignature {
554 message: error.to_string(),
555 })?;
556
557 let kid = header.kid.ok_or_else(|| VerifierError::InvalidClaims {
558 message: "token header missing 'kid' field".to_string(),
559 })?;
560 let alg = header.alg;
561 let typ = header.typ.ok_or_else(|| VerifierError::InvalidClaims {
562 message: "token header missing 'typ' field".to_string(),
563 })?;
564
565 if typ != "at+jwt" {
570 return Err(VerifierError::InvalidClaims {
571 message: format!("token type must be 'at+jwt', got {typ:?}"),
572 });
573 }
574 if !self.options.allowed_algorithms.contains(&alg) || is_dangerous_algorithm(alg) {
575 return Err(VerifierError::InvalidClaims {
576 message: format!("token algorithm {alg:?} is not allowed"),
577 });
578 }
579
580 let key = self.lookup_key(&kid, alg).await?;
581
582 let decoding_key =
583 DecodingKey::from_jwk(&key).map_err(|error| VerifierError::JwksUnavailable {
584 message: error.to_string(),
585 })?;
586
587 let mut validation = Validation::new(alg);
588 validation.leeway = self.options.clock_skew_seconds;
589 validation.validate_nbf = true;
590 validation.set_required_spec_claims(&[jwt_claims::EXP, jwt_claims::ISS, jwt_claims::AUD]);
591 validation.set_issuer(&[self.issuer.as_str()]);
592 validation.set_audience(&[self.resource.as_str()]);
593
594 let verified = decode::<Value>(token, &decoding_key, &validation)
595 .map_err(|error| map_jwt_error(error.kind(), &kid))?;
596 let payload = verified.claims;
597
598 let claims = build_verified_claims(&payload, &kid, self.options.clock_skew_seconds)?;
599
600 if let Some(revocation) = &self.options.revocation {
601 if let Some(breaker) = &self.circuit_breaker
612 && !breaker.allow()
613 {
614 if revocation.fail_open {
615 return Ok(claims);
619 }
620 return Err(VerifierError::MetadataUnavailable {
621 message: "authorization server circuit breaker is open".to_string(),
622 });
623 }
624
625 let auth_header =
626 build_basic_auth_header(&revocation.client_id, &revocation.client_secret);
627 let introspection_result = introspect_token(
628 &self.http,
629 self.metadata.introspection_endpoint().map_err(|error| {
630 VerifierError::MetadataUnavailable {
631 message: error.to_string(),
632 }
633 })?,
634 token,
635 &auth_header,
636 &self.fetch_settings,
637 None,
638 )
639 .await;
640
641 match introspection_result {
642 Ok(response) => {
643 if let Some(breaker) = &self.circuit_breaker {
644 breaker.record_success();
645 }
646 if !response.active {
647 return Err(VerifierError::TokenRevoked);
648 }
649 }
650 Err(error) => {
651 if let Some(breaker) = &self.circuit_breaker
662 && crate::circuit_policy::should_count_failure(&error)
663 {
664 breaker.record_failure();
665 }
666 if !revocation.fail_open {
667 return Err(VerifierError::MetadataUnavailable {
668 message: error.to_string(),
669 });
670 }
671 }
672 }
673 }
674
675 Ok(claims)
676 }
677
678 pub async fn verify(&self, token: &str) -> Result<VerifiedClaims, VerifierError> {
708 let claims = self.verify_claims_only(token).await?;
709
710 if claims
712 .raw
713 .get(jwt_claims::CNF)
714 .and_then(Value::as_object)
715 .is_some()
716 {
717 return Err(if self.options.inbound_dpop.is_none() {
718 VerifierError::DpopNotSupported
719 } else {
720 VerifierError::DpopBindingMismatch {
725 message: "access token is DPoP-bound (`cnf.jkt` present) but no DPoP \
726 request context was provided; use verify_with_context"
727 .to_string(),
728 }
729 });
730 }
731
732 Ok(claims)
733 }
734
735 pub async fn verify_with_context(
757 &self,
758 token: &str,
759 context: &DpopRequestContext,
760 ) -> Result<VerifiedClaims, VerifierError> {
761 let claims = self.verify_claims_only(token).await?;
762
763 let cnf_object = claims.raw.get(jwt_claims::CNF).and_then(Value::as_object);
764 let token_is_bound = cnf_object.is_some();
769 let proof_present = context.proof.is_some();
773
774 let Some(inbound) = self.options.inbound_dpop.as_ref() else {
781 if token_is_bound || proof_present {
782 return Err(VerifierError::DpopNotSupported);
783 }
784 return Ok(claims);
785 };
786
787 if !token_is_bound {
789 if inbound.is_required() {
791 return Err(VerifierError::DpopBindingMismatch {
792 message:
793 "Resource requires DPoP-bound access tokens but the presented token has no `cnf.jkt`"
794 .to_string(),
795 });
796 }
797
798 if proof_present {
801 return Err(VerifierError::DpopBindingMismatch {
802 message:
803 "DPoP proof presented but the access token is not DPoP-bound (`cnf.jkt` missing); \
804 the proof has nothing to bind to"
805 .to_string(),
806 });
807 }
808
809 return Ok(claims);
812 }
813
814 let cnf = cnf_object.expect("token_is_bound implies cnf_object is Some");
816 let expected_jkt = cnf
817 .get(jwt_claims::JKT)
818 .and_then(Value::as_str)
819 .filter(|value| !value.is_empty())
820 .ok_or_else(|| VerifierError::InvalidClaims {
821 message:
822 "DPoP-bound access token carries 'cnf' but is missing required 'cnf.jkt' claim"
823 .to_string(),
824 })?
825 .to_string();
826
827 let proof = context
828 .proof
829 .as_deref()
830 .ok_or(VerifierError::DpopProofMissing)?;
831
832 let allowed_algs = inbound.resolved_allowed_proof_algorithms();
835 let clock_skew = inbound.resolved_clock_skew_seconds(self.options.clock_skew_seconds);
836 let max_age =
837 inbound.resolved_max_proof_age_seconds(self.options.dpop_proof_max_age_seconds);
838
839 let dpop_options = DpopVerificationOptions {
840 expected_access_token: Some(token),
841 expected_nonce: context.nonce.as_deref(),
842 allowed_algorithms: &allowed_algs,
843 clock_skew_seconds: clock_skew,
844 max_age_seconds: max_age,
845 };
846
847 let verified = crate::dpop::verify_dpop_proof_with_jkt_and_replay(
864 proof,
865 context.method.as_str(),
866 context.url.as_str(),
867 dpop_options,
868 &expected_jkt,
869 inbound.replay_store().as_ref(),
870 )
871 .await?;
872
873 let mut claims = claims;
874 claims.dpop_proof = Some(verified);
875 Ok(claims)
876 }
877
878 fn find_key(&self, kid: &str, alg: Algorithm) -> Option<Jwk> {
879 let state = self.jwks_state.lock().expect("jwks mutex poisoned");
880 state
881 .keys
882 .iter()
883 .find(|jwk| jwk_matches(jwk, kid, alg))
884 .cloned()
885 }
886
887 async fn lookup_key(&self, kid: &str, alg: Algorithm) -> Result<Jwk, VerifierError> {
892 if let Some(binding) = self.metadata_binding.as_ref() {
901 binding.refresh_if_due().await;
902 }
903
904 if let Some(cache) = self.jwks_cache.as_ref() {
905 let alg_label = alg_jose_label(alg);
906 let mut found = cache
907 .get_key_by_kid(kid, alg_label)
908 .await
909 .map_err(|error| VerifierError::JwksUnavailable {
910 message: error.to_string(),
911 })?;
912
913 if found.is_none()
927 && let Some(binding) = self.metadata_binding.as_ref()
928 && binding.refresh_on_kid_miss().await
929 {
930 found = cache
931 .get_key_by_kid(kid, alg_label)
932 .await
933 .map_err(|error| VerifierError::JwksUnavailable {
934 message: error.to_string(),
935 })?;
936 }
937
938 let value = found.ok_or_else(|| VerifierError::InvalidSignature {
939 message: format!("token kid {kid:?} not found in JWKS after refresh"),
940 })?;
941 let jwk: Jwk =
942 serde_json::from_value(value).map_err(|error| VerifierError::JwksUnavailable {
943 message: error.to_string(),
944 })?;
945 return Ok(jwk);
946 }
947
948 if let Some(found) = self.find_key(kid, alg) {
950 return Ok(found);
951 }
952 if self.should_refresh_jwks() {
953 self.refresh_jwks().await?;
954 }
955 self.find_key(kid, alg)
956 .ok_or_else(|| VerifierError::InvalidSignature {
957 message: format!("token kid {kid:?} not found in JWKS after refresh"),
958 })
959 }
960
961 async fn refresh_jwks(&self) -> Result<(), VerifierError> {
962 let keys = fetch_jwks(&self.metadata.jwks_uri, &self.fetch_settings)
963 .await
964 .map_err(|message| VerifierError::JwksUnavailable { message })?;
965
966 let mut state = self.jwks_state.lock().expect("jwks mutex poisoned");
967 state.keys = keys;
968 state.loaded_at = Instant::now();
969 Ok(())
970 }
971
972 fn should_refresh_jwks(&self) -> bool {
973 let state = self.jwks_state.lock().expect("jwks mutex poisoned");
974 state.keys.is_empty() || state.loaded_at.elapsed() >= JWKS_REFRESH_MIN_INTERVAL
975 }
976}
977
978fn validate_resource_for_construction(resource: &str) -> Result<(), VerifierError> {
993 crate::prm::validate_resource_identifier(resource).map_err(|error| {
994 VerifierError::MetadataUnavailable {
995 message: error.to_string(),
996 }
997 })
998}
999
1000fn validate_revocation_for_construction(options: &ResourceOptions) -> Result<(), VerifierError> {
1008 let Some(revocation) = &options.revocation else {
1009 return Ok(());
1010 };
1011 if revocation.client_id.trim().is_empty() || revocation.client_secret.trim().is_empty() {
1012 return Err(VerifierError::MetadataUnavailable {
1013 message: "RevocationConfig requires a confidential client: client_id and \
1014 client_secret must be non-empty. authserver >= 0.1.2 answers \
1015 active=false to unauthenticated introspection, so every token would \
1016 be rejected as revoked"
1017 .to_string(),
1018 });
1019 }
1020 Ok(())
1021}
1022
1023fn resolve_resource_metadata_url(
1029 resource: &str,
1030 options: &ResourceOptions,
1031) -> Result<String, VerifierError> {
1032 match options.resource_metadata_url() {
1033 Some(url) => Ok(url.to_string()),
1034 None => build_prm_url(resource).map_err(|error| VerifierError::MetadataUnavailable {
1035 message: error.to_string(),
1036 }),
1037 }
1038}
1039
1040async fn fetch_jwks(jwks_uri: &str, fetch_settings: &FetchSettings) -> Result<Vec<Jwk>, String> {
1041 let response = ssrf_safe_get(
1050 jwks_uri,
1051 fetch_settings,
1052 DocumentFetcher::DEFAULT_JWKS_MAX_BYTES,
1053 )
1054 .await
1055 .map_err(|error| error.to_string())?;
1056
1057 let status = response.status_code;
1058 if !is_http_success(status) {
1059 let body_hint = if response.body.is_null() {
1066 "<non-JSON response body>".to_string()
1067 } else {
1068 response.body.to_string()
1069 };
1070 return Err(format!(
1071 "jwks fetch failed with status {status}: {body_hint}"
1072 ));
1073 }
1074
1075 let jwks: JwkSet = serde_json::from_value(response.body).map_err(|error| error.to_string())?;
1076 Ok(jwks.keys)
1077}
1078
1079fn alg_jose_label(alg: Algorithm) -> Option<&'static str> {
1087 match alg {
1088 Algorithm::RS256 => Some("RS256"),
1089 Algorithm::RS384 => Some("RS384"),
1090 Algorithm::RS512 => Some("RS512"),
1091 Algorithm::PS256 => Some("PS256"),
1092 Algorithm::PS384 => Some("PS384"),
1093 Algorithm::PS512 => Some("PS512"),
1094 Algorithm::ES256 => Some("ES256"),
1095 Algorithm::ES384 => Some("ES384"),
1096 Algorithm::EdDSA => Some("EdDSA"),
1097 Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => None,
1099 }
1100}
1101
1102fn prm_alg_label(alg: &Algorithm) -> Option<String> {
1117 let label = alg_jose_label(*alg).map(str::to_string);
1118 debug_assert!(
1119 label.is_some(),
1120 "prm_alg_label received unsupported algorithm {alg:?}; \
1121 InboundDPoPOptions::with_allowed_proof_algorithms / \
1122 SUPPORTED_DPOP_ALGORITHMS should have rejected it",
1123 );
1124 label
1125}
1126
1127fn jwk_matches(jwk: &Jwk, kid: &str, alg: Algorithm) -> bool {
1128 if jwk.common.key_id.as_deref() != Some(kid) {
1129 return false;
1130 }
1131
1132 if let Some(use_hint) = &jwk.common.public_key_use
1133 && use_hint != &PublicKeyUse::Signature
1134 {
1135 return false;
1136 }
1137
1138 if let Some(key_ops) = &jwk.common.key_operations
1139 && !key_ops
1140 .iter()
1141 .any(|operation| operation == &KeyOperations::Verify)
1142 {
1143 return false;
1144 }
1145
1146 if let Some(key_alg) = jwk.common.key_algorithm
1147 && key_algorithm_to_jwt(key_alg) != Some(alg)
1148 {
1149 return false;
1150 }
1151
1152 true
1153}
1154
1155fn key_algorithm_to_jwt(value: KeyAlgorithm) -> Option<Algorithm> {
1156 match value {
1157 KeyAlgorithm::RS256 => Some(Algorithm::RS256),
1158 KeyAlgorithm::RS384 => Some(Algorithm::RS384),
1159 KeyAlgorithm::RS512 => Some(Algorithm::RS512),
1160 KeyAlgorithm::PS256 => Some(Algorithm::PS256),
1161 KeyAlgorithm::PS384 => Some(Algorithm::PS384),
1162 KeyAlgorithm::PS512 => Some(Algorithm::PS512),
1163 KeyAlgorithm::ES256 => Some(Algorithm::ES256),
1164 KeyAlgorithm::ES384 => Some(Algorithm::ES384),
1165 _ => None,
1166 }
1167}
1168
1169fn is_dangerous_algorithm(alg: Algorithm) -> bool {
1170 matches!(alg, Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512)
1171}
1172
1173fn is_supported_access_token_algorithm(alg: Algorithm) -> bool {
1179 matches!(alg, Algorithm::RS256 | Algorithm::ES256)
1180}
1181
1182fn build_verified_claims(
1183 payload: &Value,
1184 kid: &str,
1185 clock_skew_seconds: u64,
1186) -> Result<VerifiedClaims, VerifierError> {
1187 let object = payload
1188 .as_object()
1189 .ok_or_else(|| VerifierError::InvalidClaims {
1190 message: "token payload must be a JSON object".to_string(),
1191 })?;
1192
1193 let issuer = required_string(payload, jwt_claims::ISS)?;
1194 let subject = required_string(payload, jwt_claims::SUB)?;
1195 let client_id = required_string(payload, jwt_claims::CLIENT_ID)?;
1196 let jti = required_string(payload, jwt_claims::JTI)?;
1197 let expires_at = required_i64(payload, jwt_claims::EXP)?;
1198 let issued_at = required_i64(payload, jwt_claims::IAT)?;
1199 let now = unix_now();
1200 if issued_at > now + clock_skew_seconds as i64 {
1201 return Err(VerifierError::InvalidClaims {
1202 message: format!(
1203 "token 'iat' claim is in the future (iat={issued_at}, now={now}, leeway={}s)",
1204 clock_skew_seconds
1205 ),
1206 });
1207 }
1208
1209 let audience = parse_audience(payload.get(jwt_claims::AUD)).ok_or_else(|| {
1210 VerifierError::InvalidClaims {
1211 message: "token missing required 'aud' claim".to_string(),
1212 }
1213 })?;
1214
1215 let not_before = payload
1216 .get(jwt_claims::NBF)
1217 .and_then(Value::as_i64)
1218 .unwrap_or_default();
1219 let scopes = payload
1220 .get(oauth_params::SCOPE)
1221 .and_then(Value::as_str)
1222 .map(|value| {
1223 value
1224 .split_whitespace()
1225 .filter(|scope| !scope.is_empty())
1226 .map(ToString::to_string)
1227 .collect::<Vec<_>>()
1228 })
1229 .unwrap_or_default();
1230
1231 let agent_id = payload
1232 .get(jwt_claims::AGENT_ID)
1233 .and_then(Value::as_str)
1234 .unwrap_or_default()
1235 .to_string();
1236 let agent_chain = crate::json_util::string_array(payload, jwt_claims::AGENT_CHAIN);
1237
1238 let raw = object
1239 .iter()
1240 .map(|(key, value)| (key.clone(), value.clone()))
1241 .collect::<BTreeMap<_, _>>();
1242
1243 Ok(VerifiedClaims {
1244 sub: subject,
1245 client_id,
1246 scopes,
1247 issuer,
1248 audience,
1249 expires_at,
1250 issued_at,
1251 jti,
1252 kid: kid.to_string(),
1253 agent_id,
1254 agent_chain,
1255 not_before,
1256 raw,
1257 dpop_proof: None,
1258 })
1259}
1260
1261fn parse_audience(aud: Option<&Value>) -> Option<Vec<String>> {
1262 match aud {
1263 Some(Value::String(value)) if !value.is_empty() => Some(vec![value.to_string()]),
1264 Some(Value::Array(values)) => {
1265 let normalized = values
1266 .iter()
1267 .filter_map(Value::as_str)
1268 .filter(|value| !value.is_empty())
1269 .map(ToString::to_string)
1270 .collect::<Vec<_>>();
1271 if normalized.is_empty() {
1272 None
1273 } else {
1274 Some(normalized)
1275 }
1276 }
1277 _ => None,
1278 }
1279}
1280
1281fn required_string(payload: &Value, field: &str) -> Result<String, VerifierError> {
1282 payload
1283 .get(field)
1284 .and_then(Value::as_str)
1285 .filter(|value| !value.is_empty())
1286 .map(ToString::to_string)
1287 .ok_or_else(|| VerifierError::InvalidClaims {
1288 message: format!("token missing required '{field}' claim"),
1289 })
1290}
1291
1292fn required_i64(payload: &Value, field: &str) -> Result<i64, VerifierError> {
1293 payload
1294 .get(field)
1295 .and_then(Value::as_i64)
1296 .ok_or_else(|| VerifierError::InvalidClaims {
1297 message: format!("token missing required '{field}' claim"),
1298 })
1299}
1300
1301use crate::time_utils::unix_now_secs_i64 as unix_now;
1302
1303fn map_jwt_error(kind: &JwtErrorKind, kid: &str) -> VerifierError {
1304 match kind {
1305 JwtErrorKind::ExpiredSignature => VerifierError::TokenExpired,
1306 JwtErrorKind::InvalidAudience
1307 | JwtErrorKind::InvalidIssuer
1308 | JwtErrorKind::ImmatureSignature
1309 | JwtErrorKind::MissingRequiredClaim(_)
1310 | JwtErrorKind::InvalidAlgorithm => VerifierError::InvalidClaims {
1311 message: format!("{kind:?}"),
1312 },
1313 JwtErrorKind::InvalidSignature => VerifierError::InvalidSignature {
1314 message: format!("signature verification failed for kid {kid:?}"),
1315 },
1316 _ => VerifierError::InvalidSignature {
1317 message: format!("{kind:?}"),
1318 },
1319 }
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use jsonwebtoken::jwk::JwkSet;
1325 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
1326 use serde_json::json;
1327
1328 use super::{
1329 AuthplaneResource, ResourceOptions, ResourceOptionsError, RevocationConfig,
1330 build_verified_claims, jwk_matches, parse_audience,
1331 };
1332 use crate::metadata::AuthorizationServerMetadata;
1333 use crate::{DpopProofOptions, DpopRequestContext, create_dpop_proof, jwk_thumbprint_sha256};
1334 use crate::{FetchSettings, VerifierError};
1335
1336 const TEST_JWKS: &str = r#"{
1337 "keys": [
1338 {
1339 "kty": "RSA",
1340 "kid": "test-kid",
1341 "alg": "RS256",
1342 "use": "sig",
1343 "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
1344 "e": "AQAB"
1345 }
1346 ]
1347 }"#;
1348 const TEST_PRIVATE_PEM: &str = include_str!("../tests/fixtures/test-private.pem");
1349
1350 fn auth_header() -> Header {
1351 Header {
1352 alg: Algorithm::RS256,
1353 kid: Some("test-kid".to_string()),
1354 typ: Some("at+jwt".to_string()),
1355 ..Header::new(Algorithm::RS256)
1356 }
1357 }
1358
1359 fn signed_token_with_claims(claims: serde_json::Value) -> String {
1360 encode(
1361 &auth_header(),
1362 &claims,
1363 &EncodingKey::from_rsa_pem(TEST_PRIVATE_PEM.as_bytes()).expect("private key"),
1364 )
1365 .expect("token")
1366 }
1367
1368 fn resource_with_test_jwks() -> AuthplaneResource {
1369 resource_with_test_jwks_and_options(
1375 ResourceOptions::default().with_inbound_dpop(crate::InboundDPoPOptions::default()),
1376 )
1377 }
1378
1379 fn resource_with_test_jwks_and_options(options: ResourceOptions) -> AuthplaneResource {
1380 let metadata = AuthorizationServerMetadata {
1381 issuer: "https://auth.example.com".to_string(),
1382 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1383 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1384 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1385 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1386 };
1387 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1388 AuthplaneResource::from_metadata_and_jwks(
1389 "https://auth.example.com",
1390 "https://api.example.com/mcp",
1391 &["tools/read".to_string()],
1392 metadata,
1393 FetchSettings::from_dev_mode(true),
1394 options,
1395 jwks,
1396 )
1397 }
1398
1399 #[test]
1400 fn build_verified_claims_rejects_future_iat() {
1401 let payload = json!({
1402 "iss": "https://auth.example.com",
1403 "sub": "user-1",
1404 "client_id": "client-1",
1405 "aud": "https://api.example.com",
1406 "exp": 4102444800i64,
1407 "iat": 4102444800i64,
1408 "jti": "token-1"
1409 });
1410
1411 let error = build_verified_claims(
1412 &payload,
1413 "test-kid",
1414 ResourceOptions::default().clock_skew_seconds,
1415 )
1416 .expect_err("iat must fail");
1417 assert!(matches!(error, VerifierError::InvalidClaims { .. }));
1418 }
1419
1420 #[test]
1421 fn jwk_selection_honors_kid_use_and_alg() {
1422 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1423 assert!(jwk_matches(
1424 &jwks.keys[0],
1425 "test-kid",
1426 jsonwebtoken::Algorithm::RS256
1427 ));
1428 assert!(!jwk_matches(
1429 &jwks.keys[0],
1430 "wrong-kid",
1431 jsonwebtoken::Algorithm::RS256
1432 ));
1433 assert!(!jwk_matches(
1434 &jwks.keys[0],
1435 "test-kid",
1436 jsonwebtoken::Algorithm::ES256
1437 ));
1438 }
1439
1440 #[test]
1441 fn resource_prm_uses_resource_and_issuer() {
1442 let metadata = AuthorizationServerMetadata {
1443 issuer: "https://auth.example.com".to_string(),
1444 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1445 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1446 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1447 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1448 };
1449 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1450 let resource = AuthplaneResource::from_metadata_and_jwks(
1451 "https://auth.example.com",
1452 "https://api.example.com/mcp",
1453 &["tools/add".to_string()],
1454 metadata,
1455 FetchSettings::from_dev_mode(true),
1456 ResourceOptions::default(),
1457 jwks,
1458 );
1459
1460 let prm = resource.prm_response();
1461 assert_eq!(prm.resource, "https://api.example.com/mcp");
1462 assert_eq!(
1463 prm.authorization_servers,
1464 vec!["https://auth.example.com".to_string()]
1465 );
1466 assert_eq!(
1467 resource.prm_document_url().expect("valid prm document url"),
1468 "https://api.example.com/.well-known/oauth-protected-resource/mcp"
1469 );
1470 }
1471
1472 fn try_construct_resource(resource: &str) -> Result<AuthplaneResource, VerifierError> {
1476 let metadata = AuthorizationServerMetadata {
1477 issuer: "https://auth.example.com".to_string(),
1478 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1479 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1480 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1481 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1482 };
1483 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
1484 AuthplaneResource::from_prefetched_metadata(
1485 "https://auth.example.com",
1486 resource,
1487 &["tools/read".to_string()],
1488 metadata,
1489 FetchSettings::from_dev_mode(true),
1490 ResourceOptions::default(),
1491 jwks,
1492 )
1493 }
1494
1495 fn assert_construction_rejects_resource(resource: &str, expected_message: &str) {
1496 let error = try_construct_resource(resource)
1497 .map(|_| ())
1498 .expect_err("construction must reject the resource identifier");
1499 let VerifierError::MetadataUnavailable { message } = error else {
1500 panic!("expected MetadataUnavailable, got {error:?}");
1501 };
1502 assert!(
1503 message.contains(expected_message),
1504 "unexpected message: {message}"
1505 );
1506 }
1507
1508 const ABSOLUTE_URL_MESSAGE: &str =
1509 "resource identifier must be an absolute URL with a scheme and a host";
1510
1511 #[test]
1512 fn construction_rejects_relative_resource_identifier() {
1513 assert_construction_rejects_resource("/mcp", ABSOLUTE_URL_MESSAGE);
1517 }
1518
1519 #[test]
1520 fn construction_rejects_scheme_relative_resource_identifier() {
1521 assert_construction_rejects_resource("//api.example.com/mcp", ABSOLUTE_URL_MESSAGE);
1524 }
1525
1526 #[test]
1527 fn construction_rejects_opaque_resource_identifier() {
1528 assert_construction_rejects_resource("urn:example:api", ABSOLUTE_URL_MESSAGE);
1530 }
1531
1532 #[test]
1533 fn construction_rejects_fragment_bearing_resource_identifier() {
1534 assert_construction_rejects_resource(
1537 "https://api.example.com/mcp#v2",
1538 "resource identifier must not include a fragment component",
1539 );
1540 }
1541
1542 #[test]
1543 fn construction_rejects_userinfo_bearing_resource_identifier() {
1544 assert_construction_rejects_resource(
1547 "https://svc:pw@api.example.com/mcp",
1548 "resource identifier must not include userinfo",
1549 );
1550 }
1551
1552 #[tokio::test]
1559 async fn from_parts_rejects_bad_resource_before_any_network_call() {
1560 let metadata = AuthorizationServerMetadata {
1561 issuer: "https://auth.example.com".to_string(),
1562 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
1563 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
1564 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
1565 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
1566 };
1567 let fetch_settings = FetchSettings::from_dev_mode(true);
1568 let http =
1569 crate::transport::build_http_client(&fetch_settings).expect("valid fetch settings");
1570 let error = AuthplaneResource::from_parts(
1571 "https://auth.example.com".to_string(),
1572 "/mcp".to_string(),
1573 vec!["tools/read".to_string()],
1574 metadata,
1575 fetch_settings,
1576 ResourceOptions::default(),
1577 http,
1578 None,
1579 None,
1580 None,
1581 )
1582 .await
1583 .map(|_| ())
1584 .expect_err("from_parts must reject the resource identifier");
1585 let VerifierError::MetadataUnavailable { message } = error else {
1586 panic!("expected MetadataUnavailable, got {error:?}");
1587 };
1588 assert!(
1589 message.contains(ABSOLUTE_URL_MESSAGE),
1590 "unexpected message: {message}"
1591 );
1592 }
1593
1594 #[test]
1595 fn construction_accepts_http_localhost_resource() {
1596 let resource = try_construct_resource("http://localhost:8080/mcp")
1599 .expect("http localhost resource must construct");
1600 assert_eq!(
1601 resource.prm_document_url().expect("valid prm document url"),
1602 "http://localhost:8080/.well-known/oauth-protected-resource/mcp"
1603 );
1604 }
1605
1606 #[test]
1607 fn revocation_config_can_be_constructed() {
1608 let config = RevocationConfig {
1609 client_id: "client-1".to_string(),
1610 client_secret: "secret-1".to_string(),
1611 fail_open: true,
1612 };
1613 assert!(config.fail_open);
1614 }
1615
1616 #[test]
1617 fn parse_audience_string_single_entry() {
1618 let aud = serde_json::json!("https://api.example.com");
1619 let parsed = parse_audience(Some(&aud)).expect("string aud must parse");
1620 assert_eq!(parsed, vec!["https://api.example.com".to_string()]);
1621 }
1622
1623 #[test]
1624 fn parse_audience_string_empty_is_none() {
1625 let aud = serde_json::json!("");
1626 assert!(parse_audience(Some(&aud)).is_none());
1627 }
1628
1629 #[test]
1630 fn parse_audience_array_multiple_entries() {
1631 let aud = serde_json::json!(["https://one.example", "https://two.example"]);
1632 let parsed = parse_audience(Some(&aud)).expect("array aud must parse");
1633 assert_eq!(
1634 parsed,
1635 vec![
1636 "https://one.example".to_string(),
1637 "https://two.example".to_string(),
1638 ]
1639 );
1640 }
1641
1642 #[test]
1643 fn parse_audience_array_filters_empty_entries() {
1644 let aud = serde_json::json!(["", "https://real.example", ""]);
1645 let parsed = parse_audience(Some(&aud)).expect("array aud must parse");
1646 assert_eq!(parsed, vec!["https://real.example".to_string()]);
1647 }
1648
1649 #[test]
1650 fn parse_audience_array_of_only_empty_strings_is_none() {
1651 let aud = serde_json::json!(["", ""]);
1652 assert!(parse_audience(Some(&aud)).is_none());
1653 }
1654
1655 #[test]
1656 fn parse_audience_array_of_non_strings_is_none() {
1657 let aud = serde_json::json!([42, true]);
1660 assert!(parse_audience(Some(&aud)).is_none());
1661 }
1662
1663 #[test]
1664 fn parse_audience_object_is_none() {
1665 let aud = serde_json::json!({"not": "a valid aud"});
1666 assert!(parse_audience(Some(&aud)).is_none());
1667 }
1668
1669 #[test]
1670 fn parse_audience_missing_is_none() {
1671 assert!(parse_audience(None).is_none());
1672 }
1673
1674 #[test]
1675 fn build_verified_claims_accepts_array_audience_with_target_resource() {
1676 let payload = json!({
1680 "iss": "https://auth.example.com",
1681 "sub": "user-1",
1682 "client_id": "client-1",
1683 "aud": ["https://api.example.com", "https://other.example.com"],
1684 "exp": 4102444800i64,
1685 "iat": 1700000000i64,
1686 "jti": "token-1"
1687 });
1688 let claims = build_verified_claims(
1689 &payload,
1690 "test-kid",
1691 ResourceOptions::default().clock_skew_seconds,
1692 )
1693 .expect("array aud must decode");
1694 assert_eq!(
1695 claims.audience,
1696 vec![
1697 "https://api.example.com".to_string(),
1698 "https://other.example.com".to_string(),
1699 ]
1700 );
1701 }
1702
1703 #[test]
1704 fn build_verified_claims_string_audience_becomes_single_element_vec() {
1705 let payload = json!({
1706 "iss": "https://auth.example.com",
1707 "sub": "user-1",
1708 "client_id": "client-1",
1709 "aud": "https://api.example.com",
1710 "exp": 4102444800i64,
1711 "iat": 1700000000i64,
1712 "jti": "token-1"
1713 });
1714 let claims = build_verified_claims(
1715 &payload,
1716 "test-kid",
1717 ResourceOptions::default().clock_skew_seconds,
1718 )
1719 .expect("string aud must decode");
1720 assert_eq!(claims.audience, vec!["https://api.example.com".to_string()]);
1721 }
1722
1723 #[test]
1724 fn build_verified_claims_rejects_empty_audience() {
1725 let payload = json!({
1726 "iss": "https://auth.example.com",
1727 "sub": "user-1",
1728 "client_id": "client-1",
1729 "aud": "",
1730 "exp": 4102444800i64,
1731 "iat": 1700000000i64,
1732 "jti": "token-1"
1733 });
1734 let error = build_verified_claims(
1735 &payload,
1736 "test-kid",
1737 ResourceOptions::default().clock_skew_seconds,
1738 )
1739 .expect_err("empty aud must fail");
1740 assert!(matches!(error, VerifierError::InvalidClaims { .. }));
1741 }
1742
1743 #[test]
1744 fn build_verified_claims_rejects_missing_jti() {
1745 let payload = json!({
1746 "iss": "https://auth.example.com",
1747 "sub": "user-1",
1748 "client_id": "client-1",
1749 "aud": "https://api.example.com",
1750 "exp": 4102444800i64,
1751 "iat": 1700000000i64
1752 });
1753 let error = build_verified_claims(
1754 &payload,
1755 "test-kid",
1756 ResourceOptions::default().clock_skew_seconds,
1757 )
1758 .expect_err("missing jti must fail");
1759 assert!(matches!(error, VerifierError::InvalidClaims { .. }));
1760 }
1761
1762 #[test]
1763 fn build_verified_claims_promotes_agent_id_and_chain() {
1764 let payload = json!({
1765 "iss": "https://auth.example.com",
1766 "sub": "user-1",
1767 "client_id": "client-1",
1768 "aud": "https://api.example.com",
1769 "exp": 4102444800i64,
1770 "iat": 1700000000i64,
1771 "jti": "token-1",
1772 "agent_id": "agent-007",
1773 "agent_chain": ["orchestrator", "agent-007"]
1774 });
1775 let claims = build_verified_claims(
1776 &payload,
1777 "test-kid",
1778 ResourceOptions::default().clock_skew_seconds,
1779 )
1780 .expect("agent claims must decode");
1781 assert_eq!(claims.agent_id, "agent-007");
1782 assert_eq!(
1783 claims.agent_chain,
1784 vec!["orchestrator".to_string(), "agent-007".to_string()]
1785 );
1786 }
1787
1788 #[test]
1789 fn build_verified_claims_defaults_agent_id_to_empty_when_absent() {
1790 let payload = json!({
1791 "iss": "https://auth.example.com",
1792 "sub": "user-1",
1793 "client_id": "client-1",
1794 "aud": "https://api.example.com",
1795 "exp": 4102444800i64,
1796 "iat": 1700000000i64,
1797 "jti": "token-1"
1798 });
1799 let claims = build_verified_claims(
1800 &payload,
1801 "test-kid",
1802 ResourceOptions::default().clock_skew_seconds,
1803 )
1804 .expect("bare token must decode");
1805 assert_eq!(claims.agent_id, "");
1806 assert!(claims.agent_chain.is_empty());
1807 assert_eq!(claims.not_before, 0);
1808 }
1809
1810 #[test]
1811 fn revocation_config_fail_closed_is_the_default() {
1812 let config = RevocationConfig {
1816 client_id: "client-1".to_string(),
1817 client_secret: "secret-1".to_string(),
1818 fail_open: false,
1819 };
1820 assert!(!config.fail_open, "default posture must be fail-closed");
1821 }
1822
1823 #[tokio::test]
1824 async fn verify_with_context_accepts_bearer_token_with_request_context_and_no_proof() {
1825 let resource = resource_with_test_jwks();
1830 let token = signed_token_with_claims(json!({
1831 "iss": "https://auth.example.com",
1832 "sub": "user-1",
1833 "client_id": "client-1",
1834 "aud": "https://api.example.com/mcp",
1835 "exp": 4102444800i64,
1836 "iat": 1700000000i64,
1837 "jti": "token-1"
1838 }));
1839 let context = DpopRequestContext::new("GET", "https://api.example.com/mcp", None, None);
1840 let claims = resource
1841 .verify_with_context(&token, &context)
1842 .await
1843 .expect("bearer + request context must verify");
1844 assert_eq!(claims.client_id, "client-1");
1845 assert!(!claims.raw.contains_key("cnf"));
1846 }
1847
1848 #[tokio::test]
1856 async fn verify_rejects_a_dpop_bound_token_in_mode_3() {
1857 let resource = resource_with_test_jwks_and_options(ResourceOptions::default());
1860 let token = signed_token_with_claims(json!({
1861 "iss": "https://auth.example.com",
1862 "sub": "user-1",
1863 "client_id": "client-1",
1864 "aud": "https://api.example.com/mcp",
1865 "exp": 4102444800i64,
1866 "iat": 1700000000i64,
1867 "jti": "token-1",
1868 "cnf": { "jkt": "some-thumbprint" }
1869 }));
1870 let err = resource
1871 .verify(&token)
1872 .await
1873 .expect_err("a sender-constrained token must not pass as a bearer token");
1874 assert!(
1875 matches!(err, VerifierError::DpopNotSupported),
1876 "expected DpopNotSupported, got {err:?}"
1877 );
1878 }
1879
1880 #[tokio::test]
1881 async fn verify_rejects_a_dpop_bound_token_when_the_resource_supports_dpop() {
1882 let resource = resource_with_test_jwks();
1886 let token = signed_token_with_claims(json!({
1887 "iss": "https://auth.example.com",
1888 "sub": "user-1",
1889 "client_id": "client-1",
1890 "aud": "https://api.example.com/mcp",
1891 "exp": 4102444800i64,
1892 "iat": 1700000000i64,
1893 "jti": "token-1",
1894 "cnf": { "jkt": "some-thumbprint" }
1895 }));
1896 let err = resource
1897 .verify(&token)
1898 .await
1899 .expect_err("a bound token with no proof must not pass");
1900 assert!(
1904 matches!(err, VerifierError::DpopBindingMismatch { .. }),
1905 "expected DpopBindingMismatch, got {err:?}"
1906 );
1907 }
1908
1909 #[tokio::test]
1910 async fn verify_still_accepts_a_plain_bearer_token() {
1911 let resource = resource_with_test_jwks_and_options(ResourceOptions::default());
1914 let token = signed_token_with_claims(json!({
1915 "iss": "https://auth.example.com",
1916 "sub": "user-1",
1917 "client_id": "client-1",
1918 "aud": "https://api.example.com/mcp",
1919 "exp": 4102444800i64,
1920 "iat": 1700000000i64,
1921 "jti": "token-1"
1922 }));
1923 let claims = resource
1924 .verify(&token)
1925 .await
1926 .expect("a bearer token must still verify");
1927 assert_eq!(claims.sub, "user-1");
1928 }
1929
1930 #[tokio::test]
1931 async fn verify_with_context_rejects_dpop_bound_token_when_proof_is_missing() {
1932 let resource = resource_with_test_jwks();
1938 let token = signed_token_with_claims(json!({
1939 "iss": "https://auth.example.com",
1940 "sub": "user-1",
1941 "client_id": "client-1",
1942 "aud": "https://api.example.com/mcp",
1943 "exp": 4102444800i64,
1944 "iat": 1700000000i64,
1945 "jti": "token-1",
1946 "cnf": { "jkt": "some-thumbprint" }
1947 }));
1948 let context = DpopRequestContext::new("GET", "https://api.example.com/mcp", None, None);
1949 let err = resource
1950 .verify_with_context(&token, &context)
1951 .await
1952 .expect_err("dpop-bound token + no proof must fail");
1953 assert!(
1954 matches!(err, VerifierError::DpopProofMissing),
1955 "expected DpopProofMissing, got {err:?}"
1956 );
1957 }
1958
1959 #[tokio::test]
1960 async fn verify_with_context_accepts_matching_dpop_bound_token_and_proof() {
1961 let resource = resource_with_test_jwks();
1962 let public_jwk = json!({
1963 "kty": "RSA",
1964 "kid": "test-kid",
1965 "use": "sig",
1966 "alg": "RS256",
1967 "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
1968 "e": "AQAB"
1969 });
1970 let jkt = jwk_thumbprint_sha256(&public_jwk).expect("jkt");
1971 let token = signed_token_with_claims(json!({
1972 "iss": "https://auth.example.com",
1973 "sub": "user-1",
1974 "client_id": "client-1",
1975 "aud": "https://api.example.com/mcp",
1976 "exp": 4102444800i64,
1977 "iat": 1700000000i64,
1978 "jti": "token-1",
1979 "cnf": { "jkt": jkt }
1980 }));
1981 let proof = create_dpop_proof(
1982 "POST",
1983 "https://api.example.com/mcp",
1984 Some(&token),
1985 &DpopProofOptions {
1986 private_key_pem: TEST_PRIVATE_PEM.to_string(),
1987 public_jwk,
1988 algorithm: Algorithm::RS256,
1989 key_id: Some("test-kid".to_string()),
1990 nonce: Some("nonce-1".to_string()),
1991 proof_ttl_seconds: None,
1992 },
1993 )
1994 .expect("proof");
1995 let context = DpopRequestContext::new(
1996 "POST",
1997 "https://api.example.com/mcp",
1998 Some(proof.as_str()),
1999 Some("nonce-1"),
2000 );
2001 let claims = resource
2002 .verify_with_context(&token, &context)
2003 .await
2004 .expect("matching dpop binding must verify");
2005 assert_eq!(claims.client_id, "client-1");
2006 }
2007
2008 #[tokio::test]
2009 async fn verify_with_context_rejects_cnf_with_missing_jkt() {
2010 let resource = resource_with_test_jwks();
2014 let token = signed_token_with_claims(json!({
2015 "iss": "https://auth.example.com",
2016 "sub": "user-1",
2017 "client_id": "client-1",
2018 "aud": "https://api.example.com/mcp",
2019 "exp": 4102444800i64,
2020 "iat": 1700000000i64,
2021 "jti": "token-1",
2022 "cnf": { "x5t#S256": "unrelated" }
2023 }));
2024 let context = DpopRequestContext::new("GET", "https://api.example.com/mcp", None, None);
2025 let err = resource
2026 .verify_with_context(&token, &context)
2027 .await
2028 .expect_err("cnf without jkt must be rejected");
2029 match err {
2030 VerifierError::InvalidClaims { message } => assert!(
2031 message.to_ascii_lowercase().contains("cnf.jkt"),
2032 "error must call out cnf.jkt, got {message}"
2033 ),
2034 other => panic!("expected InvalidClaims, got {other:?}"),
2035 }
2036 }
2037
2038 #[tokio::test]
2039 async fn verify_with_context_rejects_cnf_jkt_mismatch_as_invalid_claims() {
2040 let resource = resource_with_test_jwks();
2041 let token = signed_token_with_claims(json!({
2042 "iss": "https://auth.example.com",
2043 "sub": "user-1",
2044 "client_id": "client-1",
2045 "aud": "https://api.example.com/mcp",
2046 "exp": 4102444800i64,
2047 "iat": 1700000000i64,
2048 "jti": "token-1",
2049 "cnf": { "jkt": "wrong-thumbprint" }
2050 }));
2051 let proof = create_dpop_proof(
2052 "POST",
2053 "https://api.example.com/mcp",
2054 Some(&token),
2055 &DpopProofOptions {
2056 private_key_pem: TEST_PRIVATE_PEM.to_string(),
2057 public_jwk: json!({
2058 "kty": "RSA",
2059 "kid": "test-kid",
2060 "use": "sig",
2061 "alg": "RS256",
2062 "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
2063 "e": "AQAB"
2064 }),
2065 algorithm: Algorithm::RS256,
2066 key_id: Some("test-kid".to_string()),
2067 nonce: None,
2068 proof_ttl_seconds: None,
2069 },
2070 )
2071 .expect("proof");
2072 let context = DpopRequestContext::new(
2073 "POST",
2074 "https://api.example.com/mcp",
2075 Some(proof.as_str()),
2076 None,
2077 );
2078 let err = resource
2079 .verify_with_context(&token, &context)
2080 .await
2081 .expect_err("mismatched cnf.jkt must be rejected");
2082 assert!(
2083 err.to_string().contains("cnf.jkt mismatch"),
2084 "expected cnf.jkt mismatch error, got {err:?}"
2085 );
2086 }
2087
2088 const PRM_URL: &str = "https://api.example.com/.well-known/oauth-protected-resource/mcp";
2089
2090 fn prefetched_resource(options: ResourceOptions) -> Result<AuthplaneResource, VerifierError> {
2091 let metadata = AuthorizationServerMetadata {
2092 issuer: "https://auth.example.com".to_string(),
2093 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2094 token_endpoint: None,
2095 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
2096 revocation_endpoint: None,
2097 };
2098 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2099 AuthplaneResource::from_prefetched_metadata(
2100 "https://auth.example.com",
2101 "https://api.example.com/mcp",
2102 &["tools/read".to_string()],
2103 metadata,
2104 FetchSettings::from_dev_mode(true),
2105 options,
2106 jwks,
2107 )
2108 }
2109
2110 #[test]
2113 fn resource_metadata_url_defaults_to_the_derived_prm_document_url() {
2114 let resource = resource_with_test_jwks();
2115 assert_eq!(resource.resource_metadata_url(), PRM_URL);
2116 assert_eq!(
2117 resource.prm_document_url().expect("derivable"),
2118 resource.resource_metadata_url()
2119 );
2120 let header = resource.www_authenticate(&VerifierError::TokenExpired, "api");
2121 assert_eq!(
2122 header,
2123 format!(
2124 "Bearer realm=\"api\", error=\"invalid_token\", \
2125 error_description=\"token has expired\", resource_metadata=\"{PRM_URL}\""
2126 )
2127 );
2128 }
2129
2130 #[test]
2131 fn resource_metadata_url_override_is_advertised_instead_of_the_derivation() {
2132 let custom = "https://auth.example.com/.well-known/oauth-protected-resource/calc";
2133 let options = ResourceOptions::default()
2134 .with_resource_metadata_url(custom)
2135 .expect("absolute URL");
2136 assert_eq!(options.resource_metadata_url(), Some(custom));
2137 let resource = resource_with_test_jwks_and_options(options);
2138 assert_eq!(resource.resource_metadata_url(), custom);
2139 assert_eq!(resource.prm_document_url().expect("derivable"), PRM_URL);
2142 let header = resource.www_authenticate(&VerifierError::TokenMissing, "");
2143 assert!(header.ends_with(&format!("resource_metadata=\"{custom}\"")));
2144 }
2145
2146 #[test]
2147 fn with_resource_metadata_url_rejects_non_absolute_urls() {
2148 for bad in ["/.well-known/oauth-protected-resource/mcp", "not a url", ""] {
2149 let error = ResourceOptions::default()
2150 .with_resource_metadata_url(bad)
2151 .err()
2152 .unwrap_or_else(|| panic!("{bad:?} must be rejected"));
2153 assert!(
2154 matches!(&error, ResourceOptionsError::InvalidResourceMetadataUrl(got) if got == bad),
2155 "unexpected error for {bad:?}: {error}"
2156 );
2157 }
2158 }
2159
2160 #[test]
2166 fn with_resource_metadata_url_rejects_octets_the_header_cannot_carry() {
2167 for bad in [
2168 "https://auth.example.com/prm\n",
2169 "https://auth.example.com/prm\r\n",
2170 " https://auth.example.com/prm",
2171 "https://auth.example.com/prm\tx",
2172 "https://auth.example.com/prm doc",
2173 "https://auth.example.com/prm\u{7f}",
2174 "https://auth.example.com/prm\"x",
2175 "https://auth.example.com/prm\\x",
2176 ] {
2177 let error = ResourceOptions::default()
2178 .with_resource_metadata_url(bad)
2179 .err()
2180 .unwrap_or_else(|| panic!("{bad:?} must be rejected"));
2181 assert!(
2182 matches!(&error, ResourceOptionsError::InvalidResourceMetadataUrl(got) if got == bad),
2183 "unexpected error for {bad:?}: {error}"
2184 );
2185 }
2186 }
2187
2188 #[test]
2192 fn construction_rejects_revocation_config_with_empty_credentials() {
2193 for (client_id, client_secret) in [("", "secret"), ("rs-client", ""), ("rs-client", " ")] {
2194 let options = ResourceOptions {
2195 revocation: Some(RevocationConfig {
2196 client_id: client_id.to_string(),
2197 client_secret: client_secret.to_string(),
2198 fail_open: false,
2199 }),
2200 ..ResourceOptions::default()
2201 };
2202 let error = prefetched_resource(options).expect_err("empty credentials must fail");
2203 let VerifierError::MetadataUnavailable { message } = &error else {
2204 panic!("expected MetadataUnavailable, got {error:?}");
2205 };
2206 assert!(message.contains("confidential client"), "{message}");
2207 assert!(message.contains("authserver >= 0.1.2"), "{message}");
2208 }
2209 }
2210
2211 #[test]
2212 fn construction_accepts_revocation_config_with_credentials() {
2213 let options = ResourceOptions {
2214 revocation: Some(RevocationConfig {
2215 client_id: "rs-client".to_string(),
2216 client_secret: "rs-secret".to_string(),
2217 fail_open: false,
2218 }),
2219 ..ResourceOptions::default()
2220 };
2221 prefetched_resource(options).expect("credentials present");
2222 }
2223
2224 #[test]
2225 fn with_allowed_algorithms_accepts_asymmetric_subset() {
2226 let opts = ResourceOptions::default()
2227 .with_allowed_algorithms(vec![Algorithm::ES256])
2228 .expect("ES256 is asymmetric, must be accepted");
2229 assert_eq!(opts.allowed_algorithms(), &[Algorithm::ES256]);
2230 }
2231
2232 #[test]
2233 fn with_allowed_algorithms_rejects_empty_list() {
2234 let err = ResourceOptions::default()
2235 .with_allowed_algorithms(Vec::new())
2236 .expect_err("empty list must be rejected");
2237 assert!(matches!(
2238 err,
2239 super::ResourceOptionsError::EmptyAlgorithmList
2240 ));
2241 }
2242
2243 #[test]
2244 fn with_allowed_algorithms_rejects_hmac() {
2245 for hmac in [Algorithm::HS256, Algorithm::HS384, Algorithm::HS512] {
2251 let err = ResourceOptions::default()
2252 .with_allowed_algorithms(vec![hmac])
2253 .expect_err("HMAC must be rejected at construction");
2254 assert!(
2255 matches!(err, super::ResourceOptionsError::UnsupportedAlgorithm(alg) if alg == hmac),
2256 "expected UnsupportedAlgorithm({hmac:?}), got {err:?}"
2257 );
2258 }
2259 }
2260
2261 #[test]
2262 fn with_allowed_algorithms_rejects_mixed_list_when_any_hmac() {
2263 let err = ResourceOptions::default()
2267 .with_allowed_algorithms(vec![Algorithm::RS256, Algorithm::HS256])
2268 .expect_err("mixed list with HMAC must be rejected");
2269 assert!(matches!(
2270 err,
2271 super::ResourceOptionsError::UnsupportedAlgorithm(Algorithm::HS256)
2272 ));
2273 }
2274
2275 #[test]
2276 fn with_allowed_algorithms_rejects_non_contract_asymmetric_variants() {
2277 for alg in [
2285 Algorithm::RS384,
2286 Algorithm::RS512,
2287 Algorithm::PS256,
2288 Algorithm::PS384,
2289 Algorithm::PS512,
2290 Algorithm::ES384,
2291 Algorithm::EdDSA,
2292 ] {
2293 let err = ResourceOptions::default()
2294 .with_allowed_algorithms(vec![alg])
2295 .expect_err("non-contract asymmetric alg must be rejected");
2296 assert!(
2297 matches!(err, super::ResourceOptionsError::UnsupportedAlgorithm(rejected) if rejected == alg),
2298 "expected UnsupportedAlgorithm({alg:?}), got {err:?}"
2299 );
2300 }
2301 }
2302
2303 #[test]
2304 fn with_allowed_algorithms_rejects_mixed_list_when_any_non_contract() {
2305 let err = ResourceOptions::default()
2311 .with_allowed_algorithms(vec![Algorithm::RS256, Algorithm::RS384])
2312 .expect_err("mixed list with non-contract alg must be rejected");
2313 assert!(matches!(
2314 err,
2315 super::ResourceOptionsError::UnsupportedAlgorithm(Algorithm::RS384)
2316 ));
2317 }
2318
2319 #[tokio::test]
2320 async fn verify_with_context_rejects_replayed_proof() {
2321 let resource = resource_with_test_jwks();
2328 let public_jwk = json!({
2329 "kty": "RSA",
2330 "kid": "test-kid",
2331 "use": "sig",
2332 "alg": "RS256",
2333 "n": "pza1Jk6AXrea2P-TlgPStQO4PJ8H4mCz3qaW-PqscKygy31-_T-XNpYlH948O-hS3eN0bKLLKJetWx8bSWxBlMMW4DlV-vv32kO-phwPGE0BbQ2rMfZXfEKwKbcU_hTQv3_yfo6eugv3g_9bZR16MaNOWL0fWTmmcYoD7j8mODWoTgwGnHoriRE9wLgHOkXSJ-lnV4gR3Wa0HdI1Th91kve4mMC4DxxpzZ37xh5d0wyExHSb9bssowS70hts0JD-TX46MSpgVoCcZfBefyJ9JKoVgxVZ2aYGsdR8pwVRSRYUf2CYDvKyUZ8HfoWBv4JwBO0AVqT5Eb5F-X375fULQQ",
2334 "e": "AQAB"
2335 });
2336 let jkt = jwk_thumbprint_sha256(&public_jwk).expect("jkt");
2337 let token = signed_token_with_claims(json!({
2338 "iss": "https://auth.example.com",
2339 "sub": "user-1",
2340 "client_id": "client-1",
2341 "aud": "https://api.example.com/mcp",
2342 "exp": 4102444800i64,
2343 "iat": 1700000000i64,
2344 "jti": "token-replay-1",
2345 "cnf": { "jkt": jkt }
2346 }));
2347 let proof = create_dpop_proof(
2348 "POST",
2349 "https://api.example.com/mcp",
2350 Some(&token),
2351 &DpopProofOptions {
2352 private_key_pem: TEST_PRIVATE_PEM.to_string(),
2353 public_jwk,
2354 algorithm: Algorithm::RS256,
2355 key_id: Some("test-kid".to_string()),
2356 nonce: Some("nonce-replay".to_string()),
2357 proof_ttl_seconds: None,
2358 },
2359 )
2360 .expect("proof");
2361 let context = DpopRequestContext::new(
2362 "POST",
2363 "https://api.example.com/mcp",
2364 Some(proof.as_str()),
2365 Some("nonce-replay"),
2366 );
2367 resource
2368 .verify_with_context(&token, &context)
2369 .await
2370 .expect("first verify must succeed");
2371 let err = resource
2372 .verify_with_context(&token, &context)
2373 .await
2374 .expect_err("replayed proof must be rejected");
2375 assert!(
2376 matches!(err, VerifierError::DpopReplayDetected),
2377 "expected DpopReplayDetected on replay, got {err:?}"
2378 );
2379 }
2380
2381 fn prm_resource_with_options(options: ResourceOptions) -> AuthplaneResource {
2382 let metadata = AuthorizationServerMetadata {
2383 issuer: "https://auth.example.com".to_string(),
2384 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2385 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
2386 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
2387 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
2388 };
2389 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2390 AuthplaneResource::from_metadata_and_jwks(
2391 "https://auth.example.com",
2392 "https://api.example.com/mcp",
2393 &["tools/read".to_string()],
2394 metadata,
2395 FetchSettings::from_dev_mode(true),
2396 options,
2397 jwks,
2398 )
2399 }
2400
2401 #[test]
2402 fn prm_response_mode_3_omits_dpop_fields() {
2403 let resource = prm_resource_with_options(ResourceOptions::default());
2407 let prm = resource.prm_response();
2408 assert!(
2409 prm.dpop_signing_alg_values_supported.is_none(),
2410 "Mode 3 PRM must not advertise DPoP algs"
2411 );
2412 assert!(
2413 prm.dpop_bound_access_tokens_required.is_none(),
2414 "Mode 3 PRM must not advertise dpop_bound_access_tokens_required"
2415 );
2416 }
2417
2418 #[test]
2419 fn prm_response_mode_2_advertises_dpop_optional() {
2420 let resource = prm_resource_with_options(
2424 ResourceOptions::default().with_inbound_dpop(crate::InboundDPoPOptions::default()),
2425 );
2426 let prm = resource.prm_response();
2427 assert_eq!(
2428 prm.dpop_signing_alg_values_supported,
2429 Some(vec!["ES256".to_string(), "RS256".to_string()]),
2430 "Mode 2 PRM must list the default proof algs in the documented canonical order [ES256, RS256]"
2431 );
2432 assert_eq!(prm.dpop_bound_access_tokens_required, Some(false));
2433 }
2434
2435 #[test]
2436 fn prm_response_mode_1_advertises_dpop_required() {
2437 let resource = prm_resource_with_options(
2441 ResourceOptions::default().with_inbound_dpop(crate::InboundDPoPOptions::required()),
2442 );
2443 let prm = resource.prm_response();
2444 assert_eq!(
2445 prm.dpop_signing_alg_values_supported,
2446 Some(vec!["ES256".to_string(), "RS256".to_string()]),
2447 );
2448 assert_eq!(prm.dpop_bound_access_tokens_required, Some(true));
2449 }
2450
2451 #[test]
2452 fn prm_response_respects_custom_proof_alg_subset() {
2453 let inbound = crate::InboundDPoPOptions::default()
2457 .with_allowed_proof_algorithms(vec![Algorithm::ES256])
2458 .expect("ES256 subset is valid");
2459 let resource =
2460 prm_resource_with_options(ResourceOptions::default().with_inbound_dpop(inbound));
2461 let prm = resource.prm_response();
2462 assert_eq!(
2463 prm.dpop_signing_alg_values_supported,
2464 Some(vec!["ES256".to_string()]),
2465 );
2466 assert_eq!(prm.dpop_bound_access_tokens_required, Some(false));
2467 }
2468
2469 #[test]
2470 fn dpop_proof_max_age_independent_of_clock_skew() {
2471 let default = ResourceOptions::default();
2474 assert_eq!(default.clock_skew_seconds, 30);
2475 assert_eq!(default.dpop_proof_max_age_seconds, 300);
2476
2477 let custom = ResourceOptions {
2478 clock_skew_seconds: 600, dpop_proof_max_age_seconds: 120, ..ResourceOptions::default()
2481 };
2482 assert_eq!(custom.dpop_proof_max_age_seconds, 120);
2484 assert_eq!(custom.clock_skew_seconds, 600);
2485 }
2489
2490 use std::sync::Arc;
2503
2504 use crate::CircuitBreaker;
2505
2506 fn revocation_resource_with_breaker(
2507 fail_open: bool,
2508 breaker: Arc<CircuitBreaker>,
2509 ) -> AuthplaneResource {
2510 let metadata = AuthorizationServerMetadata {
2511 issuer: "https://auth.example.com".to_string(),
2512 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2513 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
2514 introspection_endpoint: Some("https://auth.example.com/oauth/introspect".to_string()),
2515 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
2516 };
2517 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2518 let options = ResourceOptions {
2519 revocation: Some(RevocationConfig {
2520 client_id: "client-1".to_string(),
2521 client_secret: "secret-1".to_string(),
2522 fail_open,
2523 }),
2524 ..ResourceOptions::default()
2525 };
2526 AuthplaneResource::from_metadata_jwks_and_breaker(
2527 "https://auth.example.com",
2528 "https://api.example.com/mcp",
2529 &["tools/read".to_string()],
2530 metadata,
2531 FetchSettings::from_dev_mode(true),
2532 options,
2533 jwks,
2534 breaker,
2535 )
2536 }
2537
2538 fn token_for_revocation_branch() -> String {
2539 signed_token_with_claims(json!({
2540 "iss": "https://auth.example.com",
2541 "sub": "user-1",
2542 "client_id": "client-1",
2543 "aud": "https://api.example.com/mcp",
2544 "exp": 4_102_444_800i64,
2545 "iat": 1_700_000_000i64,
2546 "jti": "token-1"
2547 }))
2548 }
2549
2550 fn opened_breaker() -> Arc<CircuitBreaker> {
2551 let breaker = CircuitBreaker::with_config(1, 60.0);
2552 breaker.record_failure();
2553 assert!(
2557 !breaker.allow(),
2558 "breaker must be open before the test runs"
2559 );
2560 Arc::new(breaker)
2561 }
2562
2563 #[tokio::test]
2564 async fn verify_with_open_breaker_and_fail_open_accepts_token_without_introspection() {
2565 let resource = revocation_resource_with_breaker(true, opened_breaker());
2570 let token = token_for_revocation_branch();
2571 let claims = resource
2572 .verify(&token)
2573 .await
2574 .expect("open breaker + fail-open must accept the token");
2575 assert_eq!(claims.sub, "user-1");
2576 }
2577
2578 #[tokio::test]
2579 async fn verify_with_open_breaker_and_fail_closed_surfaces_metadata_unavailable() {
2580 let resource = revocation_resource_with_breaker(false, opened_breaker());
2584 let token = token_for_revocation_branch();
2585 let error = resource
2586 .verify(&token)
2587 .await
2588 .expect_err("open breaker + fail-closed must reject");
2589 match error {
2590 VerifierError::MetadataUnavailable { message } => {
2591 assert!(
2592 message.contains("circuit breaker"),
2593 "expected the message to name the breaker, got: {message}"
2594 );
2595 }
2596 other => panic!("expected MetadataUnavailable, got {other:?}"),
2597 }
2598 }
2599
2600 fn revocation_resource_pointing_at(
2612 introspection_url: &str,
2613 fail_open: bool,
2614 breaker: Arc<CircuitBreaker>,
2615 ) -> AuthplaneResource {
2616 let metadata = AuthorizationServerMetadata {
2617 issuer: "https://auth.example.com".to_string(),
2618 jwks_uri: "https://auth.example.com/.well-known/jwks.json".to_string(),
2619 token_endpoint: Some("https://auth.example.com/oauth/token".to_string()),
2620 introspection_endpoint: Some(introspection_url.to_string()),
2621 revocation_endpoint: Some("https://auth.example.com/oauth/revoke".to_string()),
2622 };
2623 let jwks: JwkSet = serde_json::from_str(TEST_JWKS).expect("valid jwks");
2624 let options = ResourceOptions {
2625 revocation: Some(RevocationConfig {
2626 client_id: "client-1".to_string(),
2627 client_secret: "secret-1".to_string(),
2628 fail_open,
2629 }),
2630 ..ResourceOptions::default()
2631 };
2632 AuthplaneResource::from_metadata_jwks_and_breaker(
2633 "https://auth.example.com",
2634 "https://api.example.com/mcp",
2635 &["tools/read".to_string()],
2636 metadata,
2637 FetchSettings::from_dev_mode(true),
2638 options,
2639 jwks,
2640 breaker,
2641 )
2642 }
2643
2644 #[tokio::test]
2645 async fn introspection_invalid_client_does_not_trip_breaker() {
2646 let mut server = mockito::Server::new_async().await;
2656 let introspection_url = format!("{}/oauth/introspect", server.url());
2657 let _mock = server
2658 .mock("POST", "/oauth/introspect")
2659 .with_status(401)
2660 .with_header("content-type", "application/json")
2661 .with_body(r#"{"error":"invalid_client","error_description":"bad creds"}"#)
2662 .expect_at_least(3)
2663 .create_async()
2664 .await;
2665
2666 let breaker = Arc::new(CircuitBreaker::with_config(2, 60.0));
2667 let resource = revocation_resource_pointing_at(&introspection_url, true, breaker.clone());
2668 let token = token_for_revocation_branch();
2669
2670 for _ in 0..3 {
2673 resource
2674 .verify(&token)
2675 .await
2676 .expect("fail_open swallows the introspection error");
2677 }
2678
2679 assert!(
2680 breaker.allow(),
2681 "breaker must still allow traffic after benign OAuth errors"
2682 );
2683 }
2684
2685 #[tokio::test]
2686 async fn introspection_server_error_does_trip_breaker() {
2687 let mut server = mockito::Server::new_async().await;
2692 let introspection_url = format!("{}/oauth/introspect", server.url());
2693 let _mock = server
2694 .mock("POST", "/oauth/introspect")
2695 .with_status(500)
2696 .with_header("content-type", "application/json")
2697 .with_body(r#"{"error":"server_error","error_description":"boom"}"#)
2698 .expect_at_least(2)
2699 .create_async()
2700 .await;
2701
2702 let breaker = Arc::new(CircuitBreaker::with_config(2, 60.0));
2703 let resource = revocation_resource_pointing_at(&introspection_url, true, breaker.clone());
2704 let token = token_for_revocation_branch();
2705
2706 for _ in 0..2 {
2707 resource
2708 .verify(&token)
2709 .await
2710 .expect("fail_open swallows the introspection error");
2711 }
2712
2713 assert!(
2714 !breaker.allow(),
2715 "breaker must be Open after threshold genuine AS failures"
2716 );
2717 }
2718
2719 fn jwks_document_with_kid(kid: &str) -> serde_json::Value {
2730 let mut document: serde_json::Value =
2731 serde_json::from_str(TEST_JWKS).expect("valid jwks fixture");
2732 document["keys"][0]["kid"] = json!(kid);
2733 document
2734 }
2735
2736 const TEST_PRIVATE_PEM_2: &str = include_str!("../tests/fixtures/test-private-2.pem");
2739 const TEST_MODULUS_2: &str = "xRkG21gLFZsQn9F9D-UAevGbhdQFw1htbfWdtviNQTO3jjyk3vg273zrbNcs_-KrhtTRcLlJQWgtXuSakzdw472PvGsDph8k1v_XDj_jZztXXj6K9-G_ntAFigT55CdRXw7DzjoJKbDMgIyaVASAlvOi7Vdz39iWn5BlGU4GhUyjgKJHoUee5jCWWN0c2A-N0RclR77JVbptEi8DUZ5P2UjoW1n26pkyP4Pmy2zjBlAj6S7jm7BayeiMvaaTRy_esqjzRxh8D62BbUtFpZ-Og60HXayUqjJBOnR4Pt5d515e9BiBVqWLe7hkG0TPA6fweOxN96VL50Bf24mUPPvx9Q";
2740
2741 fn jwks_document_with_kid_and_second_key(kid: &str) -> serde_json::Value {
2743 let mut document: serde_json::Value =
2744 serde_json::from_str(TEST_JWKS).expect("valid jwks fixture");
2745 document["keys"][0]["kid"] = json!(kid);
2746 document["keys"][0]["n"] = json!(TEST_MODULUS_2);
2747 document
2748 }
2749
2750 fn signed_token_with_kid_and_key(
2751 kid: &str,
2752 private_pem: &str,
2753 issuer: &str,
2754 audience: &str,
2755 jti: &str,
2756 ) -> String {
2757 let now = std::time::SystemTime::now()
2758 .duration_since(std::time::UNIX_EPOCH)
2759 .expect("system clock after the epoch")
2760 .as_secs() as i64;
2761 let claims = json!({
2762 "iss": issuer,
2763 "sub": "user-1",
2764 "client_id": "client-1",
2765 "aud": audience,
2766 "jti": jti,
2767 "iat": now,
2768 "exp": now + 300,
2769 "scope": "tools/read"
2770 });
2771 let header = Header {
2772 alg: Algorithm::RS256,
2773 kid: Some(kid.to_string()),
2774 typ: Some("at+jwt".to_string()),
2775 ..Header::new(Algorithm::RS256)
2776 };
2777 encode(
2778 &header,
2779 &claims,
2780 &EncodingKey::from_rsa_pem(private_pem.as_bytes()).expect("private key"),
2781 )
2782 .expect("token")
2783 }
2784
2785 fn signed_token_with_kid(kid: &str, issuer: &str, audience: &str, jti: &str) -> String {
2786 signed_token_with_kid_and_key(kid, TEST_PRIVATE_PEM, issuer, audience, jti)
2787 }
2788
2789 #[tokio::test]
2790 async fn kid_miss_re_reads_metadata_without_waiting_for_the_refresh_interval() {
2791 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2799
2800 let mut server = mockito::Server::new_async().await;
2801 let issuer = server.url();
2802 let audience = "https://api.example.com/mcp";
2803
2804 let rotated = Arc::new(AtomicBool::new(false));
2805 let metadata_hits = Arc::new(AtomicUsize::new(0));
2806 let v1_hits = Arc::new(AtomicUsize::new(0));
2807 let v2_hits = Arc::new(AtomicUsize::new(0));
2808
2809 let metadata_issuer = issuer.clone();
2810 let metadata_rotated = rotated.clone();
2811 let metadata_counter = metadata_hits.clone();
2812 let _metadata_mock = server
2813 .mock("GET", "/.well-known/oauth-authorization-server")
2814 .with_status(200)
2815 .with_header("content-type", "application/json")
2816 .with_body_from_request(move |_request| {
2817 metadata_counter.fetch_add(1, Ordering::SeqCst);
2818 let jwks_uri = if metadata_rotated.load(Ordering::SeqCst) {
2819 format!("{metadata_issuer}/jwks-v2.json")
2820 } else {
2821 format!("{metadata_issuer}/jwks-v1.json")
2822 };
2823 json!({ "issuer": metadata_issuer, "jwks_uri": jwks_uri })
2824 .to_string()
2825 .into_bytes()
2826 })
2827 .create_async()
2828 .await;
2829
2830 let v1_counter = v1_hits.clone();
2835 let _jwks_v1_mock = server
2836 .mock("GET", "/jwks-v1.json")
2837 .with_status(200)
2838 .with_header("content-type", "application/json")
2839 .with_body_from_request(move |_request| {
2840 v1_counter.fetch_add(1, Ordering::SeqCst);
2841 jwks_document_with_kid("jwks-v1-key")
2842 .to_string()
2843 .into_bytes()
2844 })
2845 .create_async()
2846 .await;
2847
2848 let v2_counter = v2_hits.clone();
2849 let _jwks_v2_mock = server
2850 .mock("GET", "/jwks-v2.json")
2851 .with_status(200)
2852 .with_header("content-type", "application/json")
2853 .with_body_from_request(move |_request| {
2854 v2_counter.fetch_add(1, Ordering::SeqCst);
2855 jwks_document_with_kid("jwks-v2-key")
2856 .to_string()
2857 .into_bytes()
2858 })
2859 .create_async()
2860 .await;
2861
2862 let client = crate::AuthplaneClient::builder(&issuer)
2863 .with_fetch_settings(FetchSettings::from_dev_mode(true))
2864 .with_metadata_refresh_seconds(3600)
2865 .with_jwks_refresh_seconds(3600)
2866 .build()
2867 .await
2868 .expect("client discovers the pre-rotation metadata");
2869 let resource = client
2870 .resource(audience, &["tools/read".to_string()])
2871 .await
2872 .expect("resource built through the public API");
2873
2874 resource
2875 .verify(&signed_token_with_kid(
2876 "jwks-v1-key",
2877 &issuer,
2878 audience,
2879 "jti-retired",
2880 ))
2881 .await
2882 .expect("token signed by the jwks-v1 key must verify before rotation");
2883 let metadata_hits_before = metadata_hits.load(Ordering::SeqCst);
2884
2885 rotated.store(true, Ordering::SeqCst);
2887
2888 let claims = resource
2889 .verify(&signed_token_with_kid(
2890 "jwks-v2-key",
2891 &issuer,
2892 audience,
2893 "jti-rotated",
2894 ))
2895 .await
2896 .expect("the kid miss must re-read metadata and follow the rotation");
2897 assert_eq!(claims.kid, "jwks-v2-key");
2898 assert!(
2899 metadata_hits.load(Ordering::SeqCst) > metadata_hits_before,
2900 "the kid miss must re-read metadata, not wait for the interval",
2901 );
2902 assert_eq!(
2903 v2_hits.load(Ordering::SeqCst),
2904 1,
2905 "keys must be fetched from the rotated jwks_uri",
2906 );
2907 assert!(
2908 v1_hits.load(Ordering::SeqCst) >= 1,
2909 "the pre-rotation document must have been the one serving keys",
2910 );
2911
2912 client.aclose().await;
2913 }
2914
2915 #[tokio::test]
2916 async fn kid_miss_re_read_is_floored_so_an_unknown_kid_cannot_amplify_fetches() {
2917 use std::sync::atomic::{AtomicUsize, Ordering};
2923
2924 let mut server = mockito::Server::new_async().await;
2925 let issuer = server.url();
2926 let audience = "https://api.example.com/mcp";
2927
2928 let metadata_hits = Arc::new(AtomicUsize::new(0));
2929 let metadata_issuer = issuer.clone();
2930 let metadata_counter = metadata_hits.clone();
2931 let _metadata_mock = server
2932 .mock("GET", "/.well-known/oauth-authorization-server")
2933 .with_status(200)
2934 .with_header("content-type", "application/json")
2935 .with_body_from_request(move |_request| {
2936 metadata_counter.fetch_add(1, Ordering::SeqCst);
2937 json!({
2938 "issuer": metadata_issuer,
2939 "jwks_uri": format!("{metadata_issuer}/jwks-v1.json")
2940 })
2941 .to_string()
2942 .into_bytes()
2943 })
2944 .create_async()
2945 .await;
2946
2947 let _jwks_mock = server
2948 .mock("GET", "/jwks-v1.json")
2949 .with_status(200)
2950 .with_header("content-type", "application/json")
2951 .with_body(jwks_document_with_kid("jwks-v1-key").to_string())
2952 .create_async()
2953 .await;
2954
2955 let client = crate::AuthplaneClient::builder(&issuer)
2956 .with_fetch_settings(FetchSettings::from_dev_mode(true))
2957 .with_metadata_refresh_seconds(3600)
2958 .with_jwks_refresh_seconds(3600)
2959 .build()
2960 .await
2961 .expect("client discovers metadata");
2962 let resource = client
2963 .resource(audience, &["tools/read".to_string()])
2964 .await
2965 .expect("resource built through the public API");
2966
2967 let boot_hits = metadata_hits.load(Ordering::SeqCst);
2968 for index in 0..8 {
2969 let token =
2970 signed_token_with_kid("unknown-kid", &issuer, audience, &format!("jti-{index}"));
2971 resource
2972 .verify(&token)
2973 .await
2974 .expect_err("an unknown kid must not verify");
2975 }
2976
2977 assert_eq!(
2978 metadata_hits.load(Ordering::SeqCst) - boot_hits,
2979 1,
2980 "the forced re-read floor must admit one discovery fetch, not one per request",
2981 );
2982
2983 client.aclose().await;
2984 }
2985
2986 #[tokio::test]
2987 async fn interval_re_read_follows_a_rotation_that_republishes_the_same_kid() {
2988 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2998
2999 let mut server = mockito::Server::new_async().await;
3000 let issuer = server.url();
3001 let audience = "https://api.example.com/mcp";
3002 let shared_kid = "shared-kid";
3003
3004 let rotated = Arc::new(AtomicBool::new(false));
3005 let metadata_hits = Arc::new(AtomicUsize::new(0));
3006 let v1_hits = Arc::new(AtomicUsize::new(0));
3007 let v2_hits = Arc::new(AtomicUsize::new(0));
3008
3009 let metadata_issuer = issuer.clone();
3010 let metadata_rotated = rotated.clone();
3011 let metadata_counter = metadata_hits.clone();
3012 let _metadata_mock = server
3013 .mock("GET", "/.well-known/oauth-authorization-server")
3014 .with_status(200)
3015 .with_header("content-type", "application/json")
3016 .with_body_from_request(move |_request| {
3017 metadata_counter.fetch_add(1, Ordering::SeqCst);
3018 let jwks_uri = if metadata_rotated.load(Ordering::SeqCst) {
3019 format!("{metadata_issuer}/jwks-v2.json")
3020 } else {
3021 format!("{metadata_issuer}/jwks-v1.json")
3022 };
3023 json!({ "issuer": metadata_issuer, "jwks_uri": jwks_uri })
3024 .to_string()
3025 .into_bytes()
3026 })
3027 .create_async()
3028 .await;
3029
3030 let v1_counter = v1_hits.clone();
3031 let _jwks_v1_mock = server
3032 .mock("GET", "/jwks-v1.json")
3033 .with_status(200)
3034 .with_header("content-type", "application/json")
3035 .with_body_from_request(move |_request| {
3036 v1_counter.fetch_add(1, Ordering::SeqCst);
3037 jwks_document_with_kid("shared-kid")
3038 .to_string()
3039 .into_bytes()
3040 })
3041 .create_async()
3042 .await;
3043
3044 let v2_counter = v2_hits.clone();
3045 let _jwks_v2_mock = server
3046 .mock("GET", "/jwks-v2.json")
3047 .with_status(200)
3048 .with_header("content-type", "application/json")
3049 .with_body_from_request(move |_request| {
3050 v2_counter.fetch_add(1, Ordering::SeqCst);
3051 jwks_document_with_kid_and_second_key("shared-kid")
3052 .to_string()
3053 .into_bytes()
3054 })
3055 .create_async()
3056 .await;
3057
3058 let client = crate::AuthplaneClient::builder(&issuer)
3062 .with_fetch_settings(FetchSettings::from_dev_mode(true))
3063 .with_metadata_refresh_seconds(1)
3064 .with_jwks_refresh_seconds(3600)
3065 .build()
3066 .await
3067 .expect("client discovers the pre-rotation metadata");
3068 let resource = client
3069 .resource(audience, &["tools/read".to_string()])
3070 .await
3071 .expect("resource built through the public API");
3072
3073 resource
3074 .verify(&signed_token_with_kid_and_key(
3075 shared_kid,
3076 TEST_PRIVATE_PEM,
3077 &issuer,
3078 audience,
3079 "jti-original",
3080 ))
3081 .await
3082 .expect("token signed by the original key must verify before rotation");
3083
3084 rotated.store(true, Ordering::SeqCst);
3086 let v1_hits_at_rotation = v1_hits.load(Ordering::SeqCst);
3087
3088 tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
3092
3093 let claims = resource
3094 .verify(&signed_token_with_kid_and_key(
3095 shared_kid,
3096 TEST_PRIVATE_PEM_2,
3097 &issuer,
3098 audience,
3099 "jti-rotated",
3100 ))
3101 .await
3102 .expect("the due re-read must rebind and fetch the same kid's new material");
3103 assert_eq!(claims.kid, shared_kid);
3104 assert!(
3105 v2_hits.load(Ordering::SeqCst) >= 1,
3106 "keys must have been re-fetched from the rotated jwks_uri",
3107 );
3108 assert_eq!(
3109 v1_hits.load(Ordering::SeqCst),
3110 v1_hits_at_rotation,
3111 "no further fetch of the withdrawn document once the rebind happened",
3112 );
3113
3114 client.aclose().await;
3115 }
3116}