use crate::domain::mcp::authorization::AuthorizationModel;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthorizationResponseError {
#[error(
"the authorization response names issuer `{presented}`, but this client asked `{expected}`"
)]
IssuerMismatch {
expected: String,
presented: String,
},
#[error("the authorization server advertised `iss` and the response omitted it")]
IssuerMissing,
}
pub fn check_response_issuer(
model: &AuthorizationModel,
expected: &str,
presented: Option<&str>,
) -> Result<(), AuthorizationResponseError> {
if !model.issuer_parameter {
return Ok(());
}
match presented {
None => Ok(()),
Some(presented) if presented == expected => Ok(()),
Some(presented) => Err(AuthorizationResponseError::IssuerMismatch {
expected: expected.to_owned(),
presented: presented.to_owned(),
}),
}
}
pub fn check_response_issuer_advertised(
model: &AuthorizationModel,
expected: &str,
presented: Option<&str>,
advertised: bool,
) -> Result<(), AuthorizationResponseError> {
if model.issuer_parameter && advertised && presented.is_none() {
return Err(AuthorizationResponseError::IssuerMissing);
}
check_response_issuer(model, expected, presented)
}
#[cfg(test)]
mod tests {
use super::{AuthorizationResponseError, check_response_issuer};
use crate::domain::ProtocolVersion;
use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
const AS: &str = "https://auth.example.com";
fn model_for(version: ProtocolVersion) -> AuthorizationModel {
authorization_model(version)
}
#[test]
fn a_matching_issuer_is_accepted() {
let model = model_for(ProtocolVersion::V2026_07_28);
assert!(check_response_issuer(&model, AS, Some(AS)).is_ok());
}
#[test]
fn an_absent_issuer_is_tolerated() {
for version in [ProtocolVersion::V2025_11_25, ProtocolVersion::V2026_07_28] {
let model = model_for(version);
assert!(
check_response_issuer(&model, AS, None).is_ok(),
"{version:?} tolerates an absent iss"
);
}
}
#[test]
fn a_different_issuer_is_rejected() {
let model = model_for(ProtocolVersion::V2026_07_28);
let outcome = check_response_issuer(&model, AS, Some("https://auth.attacker.example"));
assert_eq!(
outcome,
Err(AuthorizationResponseError::IssuerMismatch {
expected: String::from(AS),
presented: String::from("https://auth.attacker.example"),
})
);
}
#[test]
fn normalisation_does_not_make_a_different_issuer_match() {
let model = model_for(ProtocolVersion::V2026_07_28);
for presented in [
"https://auth.example.com/",
"https://AUTH.EXAMPLE.COM",
"https://auth.example.com:443",
"http://auth.example.com",
" https://auth.example.com",
"https://auth.example.com#",
] {
assert!(
check_response_issuer(&model, AS, Some(presented)).is_err(),
"`{presented}` differs only by normalisation and must be rejected"
);
}
}
#[test]
fn a_revision_that_does_not_require_the_parameter_does_not_check_it() {
for version in [
ProtocolVersion::V2024_11_05,
ProtocolVersion::V2025_03_26,
ProtocolVersion::V2025_06_18,
ProtocolVersion::V2025_11_25,
] {
let model = model_for(version);
assert!(
!model.issuer_parameter,
"{version:?} must not require issuer validation"
);
assert!(
check_response_issuer(&model, AS, Some("https://auth.attacker.example")).is_ok(),
"{version:?} does not inspect iss"
);
}
}
}