use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
use base64::Engine as _;
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use serde_json::json;
use crate::application::oauth::{Refusal, ScopePolicy, SourceError, TokenClaims, TokenSource};
use crate::domain::registry::{Scope, ScopeSet};
use super::claims::{JwtClaims, instant};
use super::key_set::{KeyError, select};
use super::{JwtSource, default_algorithms};
const SECRET: &[u8] = b"a-test-key-that-is-not-a-real-secret";
const KID: &str = "current";
#[derive(Debug, thiserror::Error)]
enum Failure {
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Encode(#[from] jsonwebtoken::errors::Error),
#[error(transparent)]
Source(#[from] SourceError),
#[error(transparent)]
Refused(#[from] Refusal),
#[error(transparent)]
Uri(#[from] crate::domain::mcp::ResourceUriError),
#[error(transparent)]
Key(#[from] KeyError),
}
fn set(keys: Vec<serde_json::Value>) -> Result<JwkSet, Failure> {
Ok(JwkSet {
keys: keys
.into_iter()
.map(serde_json::from_value)
.collect::<Result<Vec<_>, _>>()?,
})
}
fn published() -> Result<JwkSet, Failure> {
set(vec![json!({
"kty": "oct",
"kid": KID,
"alg": "HS256",
"k": secret_b64(),
})])
}
fn secret_b64() -> String {
base64url(SECRET)
}
fn claims_json() -> serde_json::Value {
json!({
"sub": "alice",
"iss": "https://auth.example",
"aud": "https://mcp.example/mcp",
"exp": 1_700_000_060,
"scope": "mcp:tools",
})
}
fn signed(claims: &serde_json::Value) -> Result<String, Failure> {
Ok(encode(
&Header::new(Algorithm::HS256),
claims,
&EncodingKey::from_secret(SECRET),
)?)
}
fn signed_named(claims: &serde_json::Value, kid: &str) -> Result<String, Failure> {
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(kid.to_owned());
Ok(encode(&header, claims, &EncodingKey::from_secret(SECRET))?)
}
fn header(alg: Algorithm) -> Header {
let mut header = Header::new(alg);
header.kid = Some(KID.to_owned());
header
}
fn source_over(keys: JwkSet, accepted: Vec<Algorithm>) -> JwtSource {
let source = JwtSource::new("http://127.0.0.1:1/unused.json", accepted);
source.seed(keys);
source
}
fn scope_strings(claims: &TokenClaims) -> Vec<&str> {
claims.scopes().iter().map(Arc::as_ref).collect()
}
struct Permissive;
impl ScopePolicy for Permissive {
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()
}
}
#[test]
fn claims_survive_the_mapping() -> Result<(), Failure> {
let claims: JwtClaims = serde_json::from_value(claims_json())?;
let mapped = claims.into_token_claims();
assert_eq!(mapped.subject(), Some("alice"));
assert_eq!(mapped.issuer(), Some("https://auth.example"));
assert_eq!(scope_strings(&mapped), ["mcp:tools"]);
assert_eq!(mapped.audience().len(), 1);
Ok(())
}
#[test]
fn an_audience_array_is_kept_element_by_element() -> Result<(), Failure> {
let claims: JwtClaims = serde_json::from_value(json!({
"sub": "alice",
"aud": ["https://mcp.example/mcp", "https://other.example/mcp"],
}))?;
assert_eq!(claims.into_token_claims().audience().len(), 2);
Ok(())
}
#[test]
fn an_audience_that_is_not_a_uri_is_kept_rather_than_dropped() -> Result<(), Failure> {
let claims: JwtClaims = serde_json::from_value(json!({
"sub": "alice",
"aud": ["not a uri", "https://mcp.example/mcp"],
}))?;
assert_eq!(claims.into_token_claims().audience().len(), 2);
let guid = "8f7c1e42-0b3a-4f5d-9c6e-1a2b3c4d5e6f";
let claims: JwtClaims = serde_json::from_value(json!({
"sub": "alice",
"aud": guid,
}))?;
let mapped = claims.into_token_claims();
assert_eq!(mapped.audience().len(), 1);
assert!(mapped.is_audience_str(guid));
let claims: JwtClaims = serde_json::from_value(json!({ "sub": "alice" }))?;
assert!(claims.into_token_claims().audience().is_empty());
Ok(())
}
#[test]
fn scopes_are_read_from_both_specified_spellings() -> Result<(), Failure> {
let delimited: JwtClaims = serde_json::from_value(json!({ "scope": "a b c" }))?;
assert_eq!(
scope_strings(&delimited.into_token_claims()),
["a", "b", "c"]
);
let listed: JwtClaims = serde_json::from_value(json!({ "scope": ["a", "b"] }))?;
assert_eq!(scope_strings(&listed.into_token_claims()), ["a", "b"]);
Ok(())
}
#[test]
fn a_tab_in_scope_is_not_a_separator() -> Result<(), Failure> {
let claims: JwtClaims = serde_json::from_value(json!({ "scope": "a\tb" }))?;
assert_eq!(scope_strings(&claims.into_token_claims()), ["a\tb"]);
Ok(())
}
#[test]
fn an_empty_scope_is_not_a_scope() -> Result<(), Failure> {
let claims: JwtClaims = serde_json::from_value(json!({ "scope": "" }))?;
assert!(claims.into_token_claims().scopes().is_empty());
Ok(())
}
#[test]
fn a_numeric_date_is_read_as_seconds_since_the_epoch() {
assert_eq!(
instant(1_700_000_060.5),
Some(UNIX_EPOCH + Duration::from_millis(1_700_000_060_500))
);
assert_eq!(
instant(1_700_000_060.0),
Some(UNIX_EPOCH + Duration::from_secs(1_700_000_060))
);
}
#[test]
fn a_numeric_date_that_is_not_a_date_is_dropped() {
for seconds in [-1.0, f64::NAN, f64::INFINITY] {
assert_eq!(instant(seconds), None, "{seconds} must not become a date");
}
}
#[test]
fn a_non_numeric_date_makes_the_payload_unreadable() {
let parsed = serde_json::from_value::<JwtClaims>(json!({ "exp": "not a number" }));
assert!(parsed.is_err(), "a string must not become a NumericDate");
}
#[test]
fn a_matching_key_is_selected() -> Result<(), Failure> {
let keys = published()?;
let chosen = select(&keys, &header(Algorithm::HS256))?;
assert_eq!(chosen.common.key_id.as_deref(), Some(KID));
Ok(())
}
#[test]
fn an_unknown_kid_is_refused() -> Result<(), Failure> {
let keys = published()?;
let mut header = header(Algorithm::HS256);
header.kid = Some("retired".to_owned());
assert_eq!(select(&keys, &header).err(), Some(KeyError::UnknownKey));
Ok(())
}
#[test]
fn a_missing_kid_is_ambiguous_when_the_set_holds_more_than_one_key() -> Result<(), Failure> {
let keys = set(vec![
json!({ "kty": "oct", "kid": "one", "k": secret_b64() }),
json!({ "kty": "oct", "kid": "two", "k": secret_b64() }),
])?;
let mut header = header(Algorithm::HS256);
header.kid = None;
assert_eq!(select(&keys, &header).err(), Some(KeyError::NoKeyId));
Ok(())
}
#[test]
fn a_missing_kid_is_unambiguous_for_a_single_key() -> Result<(), Failure> {
let keys = published()?;
let mut header = header(Algorithm::HS256);
header.kid = None;
let chosen = select(&keys, &header)?;
assert_eq!(chosen.common.key_id.as_deref(), Some(KID));
Ok(())
}
#[test]
fn a_key_published_for_encryption_is_refused() -> Result<(), Failure> {
let keys = set(vec![
json!({ "kty": "oct", "kid": KID, "alg": "HS256", "k": secret_b64(), "use": "enc" }),
])?;
assert_eq!(
select(&keys, &header(Algorithm::HS256)).err(),
Some(KeyError::NotForSignatures)
);
Ok(())
}
#[test]
fn a_key_that_may_not_verify_is_refused() -> Result<(), Failure> {
let keys = set(vec![
json!({ "kty": "oct", "kid": KID, "alg": "HS256", "k": secret_b64(), "key_ops": ["encrypt"] }),
])?;
assert_eq!(
select(&keys, &header(Algorithm::HS256)).err(),
Some(KeyError::NotForVerification)
);
Ok(())
}
#[test]
fn a_key_published_for_another_algorithm_is_refused() -> Result<(), Failure> {
let keys = set(vec![
json!({ "kty": "oct", "kid": KID, "alg": "RS256", "k": secret_b64() }),
])?;
assert_eq!(
select(&keys, &header(Algorithm::HS256)).err(),
Some(KeyError::AlgorithmMismatch)
);
Ok(())
}
fn rsa_published() -> Result<JwkSet, Failure> {
set(vec![json!({
"kty": "RSA",
"kid": KID,
"n": "sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23bOdgWp4Dy1WlUzewbgBHod5pcM9H95GQRV3JDXboIRROSBigeC5yjU1hGzHHyXss8UDprecbAYxknTcQkhslANGRUZmdTOQ5qTRsLAt6BTYuyvVRdhS8exSZEy_c4gs_7svlJJQ4H9_NxsiIoLwAEk7-Q3UXERGYw_75IDrGA84-lA_-Ct4eTlXHBIY2EaV7t7LjJaynVJCpkv4LKjTTAumiGUIuQhrNhZLuF_RJLqHpM2kgWFLU7-VTdL1VbC2tejvcI2BlMkEpk1BzBZI0KQB0GaDWFLN-aEAw3vRw",
"e": "AQAB",
})])
}
#[test]
fn a_key_that_declares_no_algorithm_still_passes_selection() -> Result<(), Failure> {
let keys = rsa_published()?;
let chosen = select(&keys, &header(Algorithm::HS256))?;
assert_eq!(chosen.common.key_id.as_deref(), Some(KID));
Ok(())
}
#[tokio::test]
async fn a_correctly_signed_token_produces_claims() -> Result<(), Failure> {
let source = source_over(published()?, vec![Algorithm::HS256]);
let claims = source.claims(&signed(&claims_json())?).await?;
assert_eq!(claims.subject(), Some("alice"));
assert_eq!(scope_strings(&claims), ["mcp:tools"]);
Ok(())
}
#[tokio::test]
async fn the_claims_reach_verify_and_produce_an_auth_context() -> Result<(), Failure> {
use crate::application::oauth::{TokenPolicy, verify};
use crate::domain::ProtocolVersion;
use crate::domain::authorization_model;
use crate::domain::mcp::ResourceUri;
let source = source_over(published()?, vec![Algorithm::HS256]);
let claims = source.claims(&signed(&claims_json())?).await?;
let now = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let resource = ResourceUri::new("https://mcp.example/mcp")?;
let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2025_06_18));
let context = verify(&claims, &policy, &Permissive, now)?;
assert_eq!(context.principal(), "alice");
assert!(context.holds(ScopeSet::empty().with(Scope::Read)));
Ok(())
}
#[tokio::test]
async fn a_tampered_payload_is_refused() -> Result<(), Failure> {
let source = source_over(published()?, vec![Algorithm::HS256]);
let original = signed(&claims_json())?;
let borrowed = signed(&json!({
"sub": "root",
"aud": "https://mcp.example/mcp",
"scope": "mcp:tools",
}))?;
let (head, _original_payload, signature) = segments_of(&original)?;
let spliced = format!("{head}.{}.{signature}", payload_of(&borrowed)?);
assert!(matches!(
source.claims(&spliced).await,
Err(SourceError::Refused(Refusal::Malformed))
));
Ok(())
}
#[tokio::test]
async fn a_token_naming_an_unpublished_key_is_refused() -> Result<(), Failure> {
let source = source_over(published()?, vec![Algorithm::HS256]);
assert!(matches!(
source
.claims(&signed_named(&claims_json(), "retired")?)
.await,
Err(SourceError::Refused(Refusal::Unverified))
));
Ok(())
}
#[tokio::test]
async fn garbage_is_refused_as_malformed() -> Result<(), Failure> {
let source = source_over(published()?, vec![Algorithm::HS256]);
assert!(matches!(
source.claims("not-a-token").await,
Err(SourceError::Refused(Refusal::Malformed))
));
Ok(())
}
#[tokio::test]
async fn an_unsecured_alg_none_token_is_refused() -> Result<(), Failure> {
let source = source_over(published()?, vec![Algorithm::HS256]);
let header = base64url(
json!({ "alg": "none", "typ": "JWT" })
.to_string()
.as_bytes(),
);
let payload = base64url(claims_json().to_string().as_bytes());
let token = format!("{header}.{payload}.");
assert!(matches!(
source.claims(&token).await,
Err(SourceError::Refused(Refusal::Malformed))
));
Ok(())
}
#[tokio::test]
async fn a_token_carrying_its_own_key_is_refused() -> Result<(), Failure> {
let keys = published()?;
let own = keys
.keys
.first()
.cloned()
.ok_or(Failure::Key(KeyError::UnknownKey))?;
let source = source_over(keys, vec![Algorithm::HS256]);
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(KID.to_owned());
header.jwk = Some(own);
let token = encode(&header, &claims_json(), &EncodingKey::from_secret(SECRET))?;
assert!(matches!(
source.claims(&token).await,
Err(SourceError::Refused(Refusal::Malformed))
));
Ok(())
}
#[tokio::test]
async fn a_token_with_a_crit_header_is_refused() -> Result<(), Failure> {
let source = source_over(published()?, vec![Algorithm::HS256]);
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(KID.to_owned());
header.crit = Some(vec!["urn:example:not-understood".to_owned()]);
let token = encode(&header, &claims_json(), &EncodingKey::from_secret(SECRET))?;
assert!(matches!(
source.claims(&token).await,
Err(SourceError::Refused(Refusal::Malformed))
));
Ok(())
}
#[tokio::test]
async fn a_token_with_no_expiry_is_handed_to_verify_rather_than_refused_here() -> Result<(), Failure>
{
let source = source_over(published()?, vec![Algorithm::HS256]);
let claims = json!({ "sub": "alice", "aud": "https://mcp.example/mcp", "scope": "mcp:tools" });
assert!(source.claims(&signed(&claims)?).await.is_ok());
Ok(())
}
#[tokio::test]
async fn an_unaccepted_algorithm_is_refused() -> Result<(), Failure> {
let source = source_over(published()?, default_algorithms());
assert!(matches!(
source.claims(&signed(&claims_json())?).await,
Err(SourceError::Refused(Refusal::Unverified))
));
Ok(())
}
#[tokio::test]
async fn an_unreachable_key_endpoint_is_unavailable_not_refused() -> Result<(), Failure> {
let source = JwtSource::new("http://127.0.0.1:1/jwks.json", vec![Algorithm::HS256]);
assert!(matches!(
source.claims(&signed(&claims_json())?).await,
Err(SourceError::Unavailable(_))
));
Ok(())
}
#[tokio::test]
async fn an_rsa_key_cannot_verify_an_hmac_token() -> Result<(), Failure> {
let source = source_over(rsa_published()?, vec![Algorithm::HS256]);
assert!(matches!(
source.claims(&signed(&claims_json())?).await,
Err(SourceError::Refused(Refusal::Unverified))
));
Ok(())
}
#[test]
fn no_error_this_source_produces_contains_the_token() -> Result<(), Failure> {
let token = signed(&claims_json())?;
for refusal in [
Refusal::Malformed,
Refusal::Unverified,
Refusal::AudienceMismatch,
] {
let rendered = SourceError::from(refusal).to_string();
assert!(
!rendered.contains(&token),
"error text leaked the token: {rendered}"
);
}
Ok(())
}
fn segments_of(token: &str) -> Result<(&str, &str, &str), Failure> {
let mut segments = token.splitn(3, '.');
match (segments.next(), segments.next(), segments.next()) {
(Some(header), Some(payload), Some(signature)) => Ok((header, payload, signature)),
_ => Err(Failure::Key(KeyError::UnknownKey)),
}
}
fn payload_of(token: &str) -> Result<&str, Failure> {
segments_of(token).map(|(_, payload, _)| payload)
}
fn base64url(bytes: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}