use std::future::{Future, ready};
use std::time::{Duration, UNIX_EPOCH};
use super::{Audience, IntrospectionResponse, IntrospectionSource, claims_from, system_time};
use crate::application::oauth::{
Refusal, ScopePolicy, SourceError, TokenClaims, TokenPolicy, TokenSource, authenticate,
};
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)]
Source(#[from] SourceError),
}
fn parse(body: &str) -> Result<IntrospectionResponse, serde_json::Error> {
serde_json::from_str(body)
}
fn interpret(body: &str) -> Result<TokenClaims, SourceError> {
match parse(body) {
Ok(response) => claims_from(response),
Err(failure) => Err(SourceError::Unavailable(failure.to_string())),
}
}
#[test]
fn an_active_token_becomes_claims() -> Result<(), Failure> {
let claims = interpret(
r#"{"active":true,"sub":"alice","aud":"https://example.com/mcp",
"scope":"mcp:read mcp:write","exp":2000000000,"iss":"https://auth.example.com"}"#,
)?;
assert_eq!(claims.subject(), Some("alice"));
assert_eq!(claims.issuer(), Some("https://auth.example.com"));
assert_eq!(claims.scopes().len(), 2);
assert_eq!(claims.audience().len(), 1);
assert_eq!(
claims.audience().first().map(ResourceUri::as_str),
Some("https://example.com/mcp"),
);
Ok(())
}
#[test]
fn an_inactive_token_is_refused_whatever_else_it_says() {
let parsed = interpret(
r#"{"active":false,"sub":"alice","aud":"https://example.com/mcp",
"scope":"mcp:read","exp":2000000000}"#,
);
assert_eq!(parsed, Err(SourceError::Refused(Refusal::Unscoped)));
}
#[test]
fn a_response_that_never_says_whether_the_token_is_active_is_unavailable() {
for body in [
r#"{"sub":"alice","aud":"https://example.com/mcp"}"#,
r#"{"scope":"mcp:read"}"#,
r"{}",
] {
assert_eq!(
interpret(body),
Err(SourceError::Unavailable(
"the introspection response has no `active` field".to_owned(),
)),
"{body}",
);
}
}
#[test]
fn scopes_are_space_delimited_not_comma_delimited() -> Result<(), Failure> {
let claims = interpret(r#"{"active":true,"scope":"mcp:read mcp:write"}"#)?;
assert_eq!(claims.scopes(), &["mcp:read".into(), "mcp:write".into()]);
let commas = interpret(r#"{"active":true,"scope":"mcp:read,mcp:write"}"#)?;
assert_eq!(
commas.scopes(),
&["mcp:read,mcp:write".into()],
"a comma is part of one scope, not a separator",
);
Ok(())
}
#[test]
fn repeated_and_trailing_spaces_do_not_invent_empty_scopes() -> Result<(), Failure> {
let claims = interpret(r#"{"active":true,"scope":" mcp:read mcp:write "}"#)?;
assert_eq!(claims.scopes(), &["mcp:read".into(), "mcp:write".into()]);
Ok(())
}
#[test]
fn an_audience_may_be_one_string_or_many() -> Result<(), Failure> {
let one = interpret(r#"{"active":true,"aud":"https://example.com/mcp"}"#)?;
assert_eq!(one.audience().len(), 1);
let many = interpret(
r#"{"active":true,"aud":["https://example.com/mcp","https://other.example/mcp"]}"#,
)?;
assert_eq!(many.audience().len(), 2);
Ok(())
}
#[test]
fn an_audience_that_is_not_an_absolute_uri_is_ignored_rather_than_fatal() -> Result<(), Failure> {
let claims = interpret(r#"{"active":true,"aud":["not-a-uri","https://example.com/mcp"]}"#)?;
assert_eq!(claims.audience().len(), 1);
assert_eq!(
claims.audience().first().map(ResourceUri::as_str),
Some("https://example.com/mcp"),
);
Ok(())
}
#[test]
fn username_is_the_subject_when_sub_is_absent() -> Result<(), Failure> {
let claims = interpret(r#"{"active":true,"username":"alice"}"#)?;
assert_eq!(claims.subject(), Some("alice"));
Ok(())
}
#[test]
fn sub_wins_over_username() -> Result<(), Failure> {
let claims = interpret(r#"{"active":true,"username":"old","sub":"alice"}"#)?;
assert_eq!(claims.subject(), Some("alice"));
Ok(())
}
#[test]
fn an_active_token_may_omit_every_optional_field() -> Result<(), Failure> {
let claims = interpret(r#"{"active":true}"#)?;
assert_eq!(claims.subject(), None);
assert!(claims.scopes().is_empty());
Ok(())
}
#[test]
fn an_absent_expiry_leaves_the_token_unbounded() -> Result<(), Failure> {
let _ = interpret(r#"{"active":true,"sub":"alice"}"#)?;
Ok(())
}
#[test]
fn unix_seconds_become_system_times() {
assert!(system_time(0).is_some_and(|at| {
at.duration_since(UNIX_EPOCH)
.is_ok_and(|d| d.as_secs() == 0)
}),);
assert!(system_time(1_700_000_000).is_some());
assert!(system_time(u64::MAX).is_none());
}
#[test]
fn an_out_of_range_expiry_is_dropped_rather_than_wrapping() -> Result<(), Failure> {
let _ = interpret(r#"{"active":true,"sub":"alice","exp":18446744073709551615}"#)?;
Ok(())
}
#[test]
fn a_malformed_body_is_unavailable_and_says_so() {
for body in [r"[]", r#""active""#, r#"{"active":"yes"}"#, "not json"] {
assert!(
matches!(interpret(body), Err(SourceError::Unavailable(_))),
"{body} should be unavailable",
);
}
}
#[test]
fn the_wire_shapes_are_what_this_module_reads() -> Result<(), Failure> {
let one = parse(r#"{"active":true,"aud":"https://example.com"}"#)
.map_err(|failure| SourceError::Unavailable(failure.to_string()))?;
assert_eq!(one.active, Some(true));
assert!(matches!(one.aud, Some(Audience::One(_))));
let many = parse(r#"{"active":false,"aud":["a"]}"#)
.map_err(|failure| SourceError::Unavailable(failure.to_string()))?;
assert!(matches!(many.aud, Some(Audience::Many(_))));
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())
}
}
struct OneScope;
impl ScopePolicy for OneScope {
fn resolve(&self, scope: &str) -> Option<Scope> {
match scope {
"mcp:read" => Some(Scope::Read),
_ => None,
}
}
}
fn claims_for(resource: &str, subject: &str, scope: &str) -> Result<TokenClaims, ResourceUriError> {
Ok(TokenClaims::new()
.with_subject(subject)
.with_audience(ResourceUri::new(resource)?)
.with_expiry(UNIX_EPOCH + Duration::from_hours(8_760))
.with_scope(scope))
}
#[tokio::test]
async fn a_source_feeds_authenticate_end_to_end() -> Result<(), Failure> {
let resource = ResourceUri::new("https://example.com/mcp")?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let source = Fixed(Ok(claims_for(resource.as_str(), "alice", "mcp:read")?));
let now = UNIX_EPOCH + Duration::from_secs(1_000);
let context = authenticate(&source, &policy, &OneScope, "a-token", now).await?;
assert_eq!(context.principal(), "alice");
assert!(context.holds(ScopeSet::empty().with(Scope::Read)));
Ok(())
}
#[tokio::test]
async fn a_refusal_from_the_source_is_not_swallowed_by_the_policy() -> Result<(), Failure> {
let resource = ResourceUri::new("https://example.com/mcp")?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let source = Fixed(Err(SourceError::Refused(Refusal::Expired)));
let now = UNIX_EPOCH + Duration::from_secs(1_000);
assert_eq!(
authenticate(&source, &policy, &OneScope, "a-token", now).await,
Err(SourceError::Refused(Refusal::Expired)),
);
Ok(())
}
#[tokio::test]
async fn an_unreachable_server_is_not_a_refusal() -> Result<(), Failure> {
let resource = ResourceUri::new("https://example.com/mcp")?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
let source = Fixed(Err(SourceError::Unavailable(
"connection refused".to_owned(),
)));
let now = UNIX_EPOCH + Duration::from_secs(1_000);
assert_eq!(
authenticate(&source, &policy, &OneScope, "a-token", now).await,
Err(SourceError::Unavailable("connection refused".to_owned())),
);
Ok(())
}
#[test]
fn constructing_a_source_opens_nothing_and_remembers_its_endpoint() {
let source = IntrospectionSource::new(
"https://auth.example.com/introspect",
"client-id",
"client-secret",
);
assert_eq!(source.endpoint(), "https://auth.example.com/introspect");
let replaced = source.with_http_client(reqwest::Client::new());
assert_eq!(
replaced.endpoint(),
"https://auth.example.com/introspect",
"swapping the client must not lose the endpoint",
);
}