use std::assert_matches;
use std::future::{Future, ready};
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::time::{Duration, SystemTime};
use super::{
NoScopes, Refusal, ScopePolicy, SourceError, TokenClaims, TokenPolicy, TokenSource,
authenticate, verify,
};
use crate::domain::mcp::{ResourceUri, ResourceUriError};
use crate::domain::registry::{Scope, ScopeSet};
use crate::domain::{ProtocolVersion, authorization_model};
#[derive(Debug, thiserror::Error)]
enum Failure {
#[error(transparent)]
Uri(#[from] ResourceUriError),
#[error(transparent)]
Refusal(#[from] Refusal),
#[error(transparent)]
Source(#[from] SourceError),
}
const RESOURCE: &str = "https://example.com/mcp";
const OTHER: &str = "https://elsewhere.example/mcp";
const SIBLING: &str = "https://example.com/other";
const NOW_SECS: u64 = 1_000;
const FUTURE_SECS: u64 = NOW_SECS + 60;
fn now() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(NOW_SECS)
}
fn in_future() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(FUTURE_SECS)
}
struct TwoScopes;
impl ScopePolicy for TwoScopes {
fn resolve(&self, scope: &str) -> Option<Scope> {
match scope {
"mcp:read" => Some(Scope::Read),
"mcp:write" => Some(Scope::Write),
_ => None,
}
}
}
fn valid_claims() -> Result<TokenClaims, ResourceUriError> {
claims_for(RESOURCE, &["mcp:read"])
}
fn claims_for(audience: &str, scopes: &[&str]) -> Result<TokenClaims, ResourceUriError> {
let mut claims = TokenClaims::new()
.with_subject("alice")
.with_audience(ResourceUri::new(audience)?)
.with_expiry(in_future());
for scope in scopes {
claims = claims.with_scope(*scope);
}
Ok(claims)
}
#[test]
fn a_valid_token_yields_a_context_with_its_scopes() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let context = verify(&valid_claims()?, &policy, &TwoScopes, now())?;
assert_eq!(context.principal(), "alice");
assert!(context.holds(ScopeSet::empty().with(Scope::Read)));
Ok(())
}
#[test]
fn a_wrong_audience_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = TokenClaims::new()
.with_subject("alice")
.with_audience(ResourceUri::new(OTHER)?)
.with_expiry(in_future())
.with_scope("mcp:read");
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::AudienceMismatch),
);
Ok(())
}
#[test]
fn an_expired_token_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = valid_claims()?.with_expiry(now() - Duration::from_secs(1));
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::Expired),
);
Ok(())
}
#[test]
fn a_token_expiring_exactly_now_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = valid_claims()?.with_expiry(now());
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::Expired),
);
Ok(())
}
#[test]
fn a_token_that_is_not_valid_yet_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = valid_claims()?.with_not_before(in_future());
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::NotYetValid),
);
Ok(())
}
#[test]
fn a_token_with_no_audience_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = TokenClaims::new()
.with_subject("alice")
.with_expiry(in_future())
.with_scope("mcp:read");
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::AudienceMismatch),
);
Ok(())
}
#[test]
fn sharing_an_audience_array_with_this_server_is_enough() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = claims_for(OTHER, &["mcp:read"])?.with_audience(resource.clone());
let context = verify(&claims, &policy, &TwoScopes, now())?;
assert_eq!(context.principal(), "alice");
Ok(())
}
#[test]
fn a_sibling_path_on_the_same_host_is_a_different_resource() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = TokenClaims::new()
.with_subject("alice")
.with_audience(ResourceUri::new(SIBLING)?)
.with_expiry(in_future())
.with_scope("mcp:read");
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::AudienceMismatch),
);
Ok(())
}
#[test]
fn an_unexpected_issuer_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28))
.with_expected_issuer("https://auth.example.com");
let claims = valid_claims()?.with_issuer("https://elsewhere.example.com");
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::IssuerMismatch),
);
Ok(())
}
#[test]
fn an_absent_issuer_is_not_a_mismatch() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28))
.with_expected_issuer("https://auth.example.com");
assert!(!policy.accepts_any_issuer());
assert!(verify(&valid_claims()?, &policy, &TwoScopes, now()).is_ok());
Ok(())
}
#[test]
fn a_token_naming_no_subject_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = TokenClaims::new()
.with_audience(resource.clone())
.with_expiry(in_future())
.with_scope("mcp:read");
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::NoSubject),
);
Ok(())
}
#[test]
fn a_token_whose_scopes_map_to_nothing_is_refused() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = claims_for(RESOURCE, &["some:scope:this:deployment:never:issued"])?;
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::Unscoped),
);
Ok(())
}
#[test]
fn an_unconfigured_deployment_refuses_every_token() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
assert_eq!(
verify(&valid_claims()?, &policy, &NoScopes, now()),
Err(Refusal::Unscoped),
);
Ok(())
}
#[test]
fn several_scopes_union_rather_than_replace() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = claims_for(RESOURCE, &["mcp:read", "mcp:write", "unknown:scope"])?;
let context = verify(&claims, &policy, &TwoScopes, now())?;
assert!(context.holds(ScopeSet::empty().with(Scope::Read).with(Scope::Write)));
Ok(())
}
#[test]
fn a_revision_without_a_framework_accepts_no_token() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2024_11_05));
assert_eq!(
verify(&valid_claims()?, &policy, &TwoScopes, now()),
Err(Refusal::AudienceMismatch),
);
Ok(())
}
#[test]
fn every_revision_with_a_framework_checks_the_audience() -> Result<(), Failure> {
for version in ProtocolVersion::KNOWN {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(version));
let claims = claims_for(OTHER, &["mcp:read"])?;
let got = verify(&claims, &policy, &TwoScopes, now());
assert_eq!(
got,
Err(Refusal::AudienceMismatch),
"{version} must refuse a foreign-audience token",
);
}
Ok(())
}
#[test]
fn expiry_is_reported_before_a_mismatch() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let claims = TokenClaims::new()
.with_subject("alice")
.with_audience(ResourceUri::new(OTHER)?)
.with_expiry(now() - Duration::from_secs(1))
.with_scope("mcp:read");
assert_eq!(
verify(&claims, &policy, &TwoScopes, now()),
Err(Refusal::Expired),
);
Ok(())
}
#[test]
fn scope_strings_reach_the_verifier_untranslated() -> Result<(), Failure> {
let claims = valid_claims()?;
assert_eq!(claims.scopes(), &[Arc::from("mcp:read")]);
let resource = ResourceUri::new(RESOURCE)?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
assert!(verify(&claims, &policy, &TwoScopes, now()).is_ok());
Ok(())
}
struct Fixed(Result<TokenClaims, SourceError>);
impl TokenSource for Fixed {
fn claims(
&self,
_presented: &str,
) -> impl Future<Output = Result<TokenClaims, SourceError>> + Send {
ready(self.0.clone())
}
}
fn block_on<F: Future>(future: F) -> F::Output {
struct ThreadWaker(std::thread::Thread);
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = Box::pin(future);
loop {
if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
return output;
}
std::thread::park();
}
}
fn policy(resource: &ResourceUri) -> TokenPolicy<'_> {
TokenPolicy::new(resource, authorization_model(ProtocolVersion::V2026_07_28))
}
#[test]
fn a_source_that_answers_produces_the_same_context_verifying_would_have() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let source = Fixed(Ok(valid_claims()?));
let context = block_on(authenticate(
&source,
&policy(&resource),
&TwoScopes,
"a-token",
now(),
))?;
assert_eq!(context.principal(), "alice");
assert!(context.holds(ScopeSet::empty().with(Scope::Read)));
Ok(())
}
#[test]
fn the_policy_still_gets_the_last_word_over_what_a_source_returned() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let source = Fixed(Ok(claims_for(OTHER, &["mcp:read"])?));
let refusal = block_on(authenticate(
&source,
&policy(&resource),
&TwoScopes,
"a-token",
now(),
));
assert_matches!(
refusal,
Err(SourceError::Refused(Refusal::AudienceMismatch))
);
Ok(())
}
#[test]
fn a_source_that_says_no_is_not_re_written_as_an_unavailable_source() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let source = Fixed(Err(SourceError::Refused(Refusal::Expired)));
let outcome = block_on(authenticate(
&source,
&policy(&resource),
&TwoScopes,
"a-token",
now(),
));
assert_matches!(outcome, Err(SourceError::Refused(Refusal::Expired)));
Ok(())
}
#[test]
fn an_unreachable_source_stays_unavailable_and_never_becomes_a_refusal() -> Result<(), Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let source = Fixed(Err(SourceError::Unavailable(
"introspection responded 503".into(),
)));
let outcome = block_on(authenticate(
&source,
&policy(&resource),
&TwoScopes,
"a-token",
now(),
));
assert_matches!(&outcome, Err(SourceError::Unavailable(_)));
if let Err(SourceError::Unavailable(reason)) = &outcome {
assert!(reason.contains("503"));
}
Ok(())
}