use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
use axum::http::{HeaderMap, HeaderValue};
use crate::adapters::primary::transport::http::{AuthPolicy, Refusal};
use crate::application::oauth::{ScopePolicy, SourceError, TokenClaims, TokenSource};
use crate::domain::ProtocolVersion;
use crate::domain::mcp::{ResourceMetadata, ResourceUri};
use crate::domain::registry::{AuthContext, Scope, ScopeSet};
use super::{Authenticator, OAuthAuthenticator, Presented, WallClock, bearer};
const RESOURCE: &str = "https://mcp.example/mcp";
fn now() -> std::time::SystemTime {
UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
#[derive(Debug, Clone, Copy)]
struct Fixed(std::time::SystemTime);
impl WallClock for Fixed {
fn now(&self) -> std::time::SystemTime {
self.0
}
}
#[derive(Debug, Default)]
struct Drifting(std::sync::atomic::AtomicU64);
impl WallClock for Drifting {
fn now(&self) -> std::time::SystemTime {
let seconds = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
UNIX_EPOCH + Duration::from_secs(seconds)
}
}
struct McpScopes;
impl ScopePolicy for McpScopes {
fn resolve(&self, scope: &str) -> Option<Scope> {
scope.starts_with("mcp:").then_some(Scope::Read)
}
fn principal(&self, subject: &str, _scopes: &[Arc<str>]) -> String {
subject.to_owned()
}
}
struct Canned {
claims: Result<TokenClaims, SourceError>,
asked: std::sync::Mutex<Vec<String>>,
}
impl Canned {
fn granting(claims: TokenClaims) -> Self {
Self {
claims: Ok(claims),
asked: std::sync::Mutex::new(Vec::new()),
}
}
fn refusing(error: SourceError) -> Self {
Self {
claims: Err(error),
asked: std::sync::Mutex::new(Vec::new()),
}
}
fn asked_for(&self) -> Vec<String> {
self.asked.lock().map_or_else(
|poisoned| poisoned.into_inner().clone(),
|asked| asked.clone(),
)
}
}
impl TokenSource for Canned {
fn claims(
&self,
presented: &str,
) -> impl Future<Output = Result<TokenClaims, SourceError>> + Send {
if let Ok(mut asked) = self.asked.lock() {
asked.push(presented.to_owned());
}
let claims = self.claims.clone();
std::future::ready(claims)
}
}
#[derive(Debug, thiserror::Error)]
enum Failure {
#[error(transparent)]
Uri(#[from] crate::domain::mcp::ResourceUriError),
}
fn good() -> TokenClaims {
TokenClaims::new()
.with_subject("alice")
.with_audience_string(RESOURCE)
.with_expiry(now() + Duration::from_secs(60))
.with_scope("mcp:tools")
}
fn authenticator(
source: Canned,
clock: impl WallClock + 'static,
) -> Result<OAuthAuthenticator<Canned>, Failure> {
let metadata = ResourceMetadata::new(ResourceUri::new(RESOURCE)?);
Ok(OAuthAuthenticator::new(
source,
metadata,
ProtocolVersion::V2025_06_18,
McpScopes,
clock,
))
}
fn accepting() -> Result<OAuthAuthenticator<Canned>, Failure> {
authenticator(Canned::granting(good()), Fixed(now()))
}
async fn context_for(
authenticator: &OAuthAuthenticator<Canned>,
header: Option<&str>,
) -> crate::domain::registry::AuthContext {
let mut headers = HeaderMap::new();
if let Some(value) = header
&& let Ok(value) = HeaderValue::from_str(value)
{
headers.insert(axum::http::header::AUTHORIZATION, value);
}
authenticator
.authenticate(&super::super::Credentials::from_headers(&headers))
.await
}
#[test]
fn the_default_clock_reads_a_real_instant() {
let clock = super::SystemClock;
let first = clock.now();
assert!(
first > UNIX_EPOCH + Duration::from_secs(1),
"the default clock returned {first:?}, which is not a real instant"
);
std::thread::sleep(Duration::from_millis(2));
assert!(clock.now() >= first);
}
#[test]
fn a_bearer_token_is_read() {
assert_eq!(bearer(Some("Bearer abc")), Presented::Bearer("abc"));
}
#[test]
fn the_scheme_is_case_insensitive() {
assert_eq!(bearer(Some("bearer abc")), Presented::Bearer("abc"));
assert_eq!(bearer(Some("BEARER abc")), Presented::Bearer("abc"));
}
#[test]
fn surrounding_whitespace_is_not_part_of_the_token() {
assert_eq!(bearer(Some("Bearer abc ")), Presented::Bearer("abc"));
}
#[test]
fn a_token_with_no_token_is_nothing() {
for header in ["Bearer", "Bearer ", "Bearer ", " Bearer "] {
assert_eq!(
bearer(Some(header)),
Presented::Nothing,
"{header:?} should carry no token"
);
}
}
#[test]
fn another_scheme_is_nothing() {
for header in ["Basic abc", "Negotiate abc", "abc", "Bearerish abc"] {
assert_eq!(
bearer(Some(header)),
Presented::Nothing,
"{header:?} is not a bearer credential"
);
}
}
#[test]
fn no_header_is_nothing() {
assert_eq!(bearer(None), Presented::Nothing);
}
#[tokio::test]
async fn a_valid_token_becomes_a_principal_with_its_scope() -> Result<(), Failure> {
let authenticator = accepting()?;
let context = context_for(&authenticator, Some("Bearer good")).await;
assert_eq!(context.principal(), "alice");
assert!(context.holds(ScopeSet::empty().with(Scope::Read)));
Ok(())
}
#[tokio::test]
async fn the_source_is_asked_about_the_bare_token() -> Result<(), Failure> {
let source = Canned::granting(good());
let authenticator = authenticator(source, Fixed(now()))?;
let _ = context_for(&authenticator, Some("Bearer abc")).await;
assert_eq!(authenticator.source.asked_for(), ["abc"]);
Ok(())
}
#[tokio::test]
async fn the_clock_is_read_once_per_request() -> Result<(), Failure> {
let authenticator = authenticator(Canned::granting(good()), Drifting::default())?;
let first = context_for(&authenticator, Some("Bearer good")).await;
assert_eq!(first.principal(), "alice");
Ok(())
}
#[tokio::test]
async fn a_missing_credential_is_anonymous() -> Result<(), Failure> {
let authenticator = accepting()?;
assert_eq!(
context_for(&authenticator, None).await,
AuthContext::anonymous()
);
Ok(())
}
#[tokio::test]
async fn a_non_bearer_credential_is_anonymous() -> Result<(), Failure> {
let authenticator = accepting()?;
for header in ["Basic abc", "Bearer", "Bearer ", "nonsense"] {
assert_eq!(
context_for(&authenticator, Some(header)).await,
AuthContext::anonymous(),
"{header:?} must not authenticate"
);
}
Ok(())
}
#[tokio::test]
async fn a_refused_token_is_anonymous() -> Result<(), Failure> {
let refusals = [
SourceError::Refused(crate::application::oauth::Refusal::Malformed),
SourceError::Refused(crate::application::oauth::Refusal::Unverified),
SourceError::Refused(crate::application::oauth::Refusal::AudienceMismatch),
SourceError::Unavailable("the authorization server is unreachable".to_owned()),
];
for error in refusals {
let authenticator = authenticator(Canned::refusing(error), Fixed(now()))?;
assert_eq!(
context_for(&authenticator, Some("Bearer good")).await,
AuthContext::anonymous()
);
}
Ok(())
}
#[tokio::test]
async fn a_token_for_another_audience_is_anonymous() -> Result<(), Failure> {
let claims = TokenClaims::new()
.with_subject("alice")
.with_audience_string("https://someone-elses-server.example/mcp")
.with_expiry(now() + Duration::from_secs(60))
.with_scope("mcp:tools");
let authenticator = authenticator(Canned::granting(claims), Fixed(now()))?;
assert_eq!(
context_for(&authenticator, Some("Bearer good")).await,
AuthContext::anonymous()
);
Ok(())
}
#[tokio::test]
async fn an_expired_token_is_anonymous() -> Result<(), Failure> {
let claims = TokenClaims::new()
.with_subject("alice")
.with_audience_string(RESOURCE)
.with_expiry(now() - Duration::from_secs(1))
.with_scope("mcp:tools");
let authenticator = authenticator(Canned::granting(claims), Fixed(now()))?;
assert_eq!(
context_for(&authenticator, Some("Bearer good")).await,
AuthContext::anonymous()
);
Ok(())
}
#[tokio::test]
async fn a_token_carrying_only_unrecognised_scopes_is_refused() -> Result<(), Failure> {
let claims = TokenClaims::new()
.with_subject("alice")
.with_audience_string(RESOURCE)
.with_expiry(now() + Duration::from_secs(60))
.with_scope("admin:everything");
let authenticator = authenticator(Canned::granting(claims), Fixed(now()))?;
assert_eq!(
context_for(&authenticator, Some("Bearer good")).await,
AuthContext::anonymous()
);
Ok(())
}
#[tokio::test]
async fn a_valid_token_missing_the_required_scope_is_403_not_401() -> Result<(), Failure> {
let authenticator = accepting()?;
let context = context_for(&authenticator, Some("Bearer good")).await;
let requiring_read = AuthPolicy::Require(ScopeSet::empty().with(Scope::Read));
let requiring_write = AuthPolicy::Require(ScopeSet::empty().with(Scope::Write));
assert_eq!(requiring_read.refuse(&context), None);
assert_eq!(
requiring_write.refuse(&context),
Some(Refusal::Forbidden),
"a valid token lacking a scope must be insufficient, not invalid"
);
Ok(())
}
#[test]
fn the_debug_formation_carries_no_credentials() -> Result<(), Failure> {
let source = Canned::granting(good());
let authenticator =
authenticator(source, Fixed(now()))?.with_expected_issuer("https://auth.example");
let rendered = format!("{authenticator:?}");
assert!(rendered.contains(RESOURCE), "{rendered}");
assert!(rendered.contains("auth.example"), "{rendered}");
Ok(())
}