use crate::metadata::error::MetadataError;
use crate::metadata::types::endpoint::{Endpoint, IndexedEndpoint};
use crate::metadata::types::entity_descriptor::EntityDescriptor;
use crate::metadata::types::idp::IdpSsoDescriptor;
use crate::metadata::types::sp::SpSsoDescriptor;
pub struct MetadataValidator {
pub require_sso_service: bool,
pub require_acs: bool,
}
impl Default for MetadataValidator {
fn default() -> Self {
MetadataValidator {
require_sso_service: true,
require_acs: true,
}
}
}
impl MetadataValidator {
pub fn new() -> Self {
Self::default()
}
pub fn validate(&self, entity: &EntityDescriptor) -> Result<(), MetadataError> {
if entity.entity_id.is_empty() {
return Err(MetadataError::SchemaViolation(
"EntityDescriptor entityID must not be empty".to_string(),
));
}
if entity.entity_id.len() > 1024 {
return Err(MetadataError::SchemaViolation(format!(
"EntityDescriptor entityID exceeds 1024 characters: {} chars",
entity.entity_id.len()
)));
}
for idp in entity.idp_sso_descriptors() {
self.validate_idp(idp)?;
}
for sp in entity.sp_sso_descriptors() {
self.validate_sp(sp)?;
}
Ok(())
}
fn validate_idp(&self, idp: &IdpSsoDescriptor) -> Result<(), MetadataError> {
if self.require_sso_service && idp.single_sign_on_services.is_empty() {
return Err(MetadataError::MissingRequiredEndpoint(
"IDPSSODescriptor must have at least one SingleSignOnService".to_string(),
));
}
for sso in &idp.single_sign_on_services {
if sso.response_location.is_some() {
return Err(MetadataError::SchemaViolation(
"SingleSignOnService MUST NOT have ResponseLocation".to_string(),
));
}
}
for nidms in &idp.name_id_mapping_services {
if nidms.response_location.is_some() {
return Err(MetadataError::SchemaViolation(
"NameIDMappingService MUST NOT have ResponseLocation".to_string(),
));
}
}
for ars in &idp.sso_base.artifact_resolution_services {
if ars.endpoint.response_location.is_some() {
return Err(MetadataError::SchemaViolation(
"ArtifactResolutionService MUST NOT have ResponseLocation".to_string(),
));
}
}
if idp.sso_base.base.protocol_support_enumeration.is_empty() {
return Err(MetadataError::SchemaViolation(
"RoleDescriptor must specify at least one supported protocol".to_string(),
));
}
Ok(())
}
fn validate_sp(&self, sp: &SpSsoDescriptor) -> Result<(), MetadataError> {
if self.require_acs && sp.assertion_consumer_services.is_empty() {
return Err(MetadataError::MissingRequiredEndpoint(
"SPSSODescriptor must have at least one AssertionConsumerService".to_string(),
));
}
for ars in &sp.sso_base.artifact_resolution_services {
if ars.endpoint.response_location.is_some() {
return Err(MetadataError::SchemaViolation(
"ArtifactResolutionService MUST NOT have ResponseLocation".to_string(),
));
}
}
if sp.sso_base.base.protocol_support_enumeration.is_empty() {
return Err(MetadataError::SchemaViolation(
"RoleDescriptor must specify at least one supported protocol".to_string(),
));
}
Ok(())
}
}
pub fn resolve_default_indexed_endpoint(endpoints: &[IndexedEndpoint]) -> Option<&IndexedEndpoint> {
if endpoints.is_empty() {
return None;
}
if let Some(ep) = endpoints.iter().find(|e| e.is_default == Some(true)) {
return Some(ep);
}
if let Some(ep) = endpoints.iter().find(|e| e.is_default.is_none()) {
return Some(ep);
}
endpoints.iter().min_by_key(|e| e.index)
}
pub fn resolve_endpoint_by_binding<'a>(
endpoints: &'a [Endpoint],
binding: &str,
) -> Option<&'a Endpoint> {
endpoints.iter().find(|e| e.binding == binding)
}
pub fn resolve_indexed_endpoint_by_binding<'a>(
endpoints: &'a [IndexedEndpoint],
binding: &str,
) -> Option<&'a IndexedEndpoint> {
endpoints.iter().find(|e| e.endpoint.binding == binding)
}
pub fn negotiate_endpoint_by_preference<'a>(
endpoints: &'a [Endpoint],
binding_preferences: &[&str],
) -> Option<&'a Endpoint> {
binding_preferences
.iter()
.find_map(|binding| resolve_endpoint_by_binding(endpoints, binding))
}
pub fn negotiate_indexed_endpoint_by_preference<'a>(
endpoints: &'a [IndexedEndpoint],
binding_preferences: &[&str],
) -> Option<&'a IndexedEndpoint> {
binding_preferences
.iter()
.find_map(|binding| resolve_indexed_endpoint_by_binding(endpoints, binding))
}
pub mod binding_preferences {
const HTTP_REDIRECT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect";
const HTTP_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
const HTTP_ARTIFACT: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact";
const SOAP: &str = "urn:oasis:names:tc:SAML:2.0:bindings:SOAP";
pub const SINGLE_SIGN_ON: &[&str] = &[HTTP_REDIRECT, HTTP_POST, HTTP_ARTIFACT];
pub const ASSERTION_CONSUMER: &[&str] = &[HTTP_POST, HTTP_REDIRECT, HTTP_ARTIFACT];
pub const SINGLE_LOGOUT: &[&str] = &[SOAP, HTTP_REDIRECT, HTTP_POST, HTTP_ARTIFACT];
pub const MANAGE_NAME_ID: &[&str] = &[SOAP, HTTP_REDIRECT, HTTP_POST, HTTP_ARTIFACT];
pub const ARTIFACT_RESOLUTION: &[&str] = &[SOAP];
pub const BACK_CHANNEL_QUERY: &[&str] = &[SOAP];
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::types::endpoint::{Endpoint, IndexedEndpoint};
use crate::metadata::types::entity_descriptor::{EntityDescriptor, EntityRoles};
use crate::metadata::types::role_descriptor::{RoleDescriptorBase, SsoDescriptorBase};
#[test]
fn test_negotiate_endpoint_by_preference() {
let endpoints = vec![
Endpoint::new(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"https://idp.example.com/sso/post",
),
Endpoint::new(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
"https://idp.example.com/sso/redirect",
),
];
let ep = negotiate_endpoint_by_preference(&endpoints, binding_preferences::SINGLE_SIGN_ON)
.unwrap();
assert!(ep.location.contains("redirect"));
let ep = negotiate_endpoint_by_preference(&endpoints, binding_preferences::SINGLE_LOGOUT)
.unwrap();
assert!(ep.location.contains("redirect"));
assert!(negotiate_endpoint_by_preference(
&endpoints,
binding_preferences::ARTIFACT_RESOLUTION
)
.is_none());
}
#[test]
fn test_negotiate_indexed_endpoint_by_preference() {
let endpoints = vec![IndexedEndpoint::new(
Endpoint::new(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"https://sp.example.com/acs",
),
0,
)];
let ep = negotiate_indexed_endpoint_by_preference(
&endpoints,
binding_preferences::ASSERTION_CONSUMER,
)
.unwrap();
assert!(ep.endpoint.location.contains("acs"));
}
fn make_sso_base() -> SsoDescriptorBase {
SsoDescriptorBase {
base: RoleDescriptorBase::new(vec!["urn:oasis:names:tc:SAML:2.0:protocol".to_string()]),
artifact_resolution_services: vec![],
single_logout_services: vec![],
manage_name_id_services: vec![],
name_id_formats: vec![],
}
}
#[test]
fn test_validate_empty_entity_id() {
let entity = EntityDescriptor {
entity_id: String::new(),
id: None,
valid_until: None,
cache_duration: None,
has_signature: false,
extensions: None,
roles: EntityRoles::Roles {
idp_sso: vec![],
sp_sso: vec![],
authn_authority: vec![],
attr_authority: vec![],
pdp: vec![],
},
organization: None,
contact_persons: vec![],
additional_metadata_locations: vec![],
};
let v = MetadataValidator::new();
assert!(v.validate(&entity).is_err());
}
#[test]
fn test_validate_idp_missing_sso() {
let entity = EntityDescriptor {
entity_id: "https://idp.example.com".to_string(),
id: None,
valid_until: None,
cache_duration: None,
has_signature: false,
extensions: None,
roles: EntityRoles::Roles {
idp_sso: vec![IdpSsoDescriptor {
sso_base: make_sso_base(),
want_authn_requests_signed: None,
single_sign_on_services: vec![], name_id_mapping_services: vec![],
assertion_id_request_services: vec![],
attribute_profiles: vec![],
attributes: vec![],
}],
sp_sso: vec![],
authn_authority: vec![],
attr_authority: vec![],
pdp: vec![],
},
organization: None,
contact_persons: vec![],
additional_metadata_locations: vec![],
};
let v = MetadataValidator::new();
let err = v.validate(&entity).unwrap_err();
assert!(err.to_string().contains("SingleSignOnService"));
}
#[test]
fn test_validate_idp_sso_response_location_rejected() {
let entity = EntityDescriptor {
entity_id: "https://idp.example.com".to_string(),
id: None,
valid_until: None,
cache_duration: None,
has_signature: false,
extensions: None,
roles: EntityRoles::Roles {
idp_sso: vec![IdpSsoDescriptor {
sso_base: make_sso_base(),
want_authn_requests_signed: None,
single_sign_on_services: vec![Endpoint::with_response_location(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
"https://idp.example.com/sso",
"https://idp.example.com/sso-response", )],
name_id_mapping_services: vec![],
assertion_id_request_services: vec![],
attribute_profiles: vec![],
attributes: vec![],
}],
sp_sso: vec![],
authn_authority: vec![],
attr_authority: vec![],
pdp: vec![],
},
organization: None,
contact_persons: vec![],
additional_metadata_locations: vec![],
};
let v = MetadataValidator::new();
let err = v.validate(&entity).unwrap_err();
assert!(err.to_string().contains("ResponseLocation"));
}
#[test]
fn test_validate_sp_missing_acs() {
let entity = EntityDescriptor {
entity_id: "https://sp.example.com".to_string(),
id: None,
valid_until: None,
cache_duration: None,
has_signature: false,
extensions: None,
roles: EntityRoles::Roles {
idp_sso: vec![],
sp_sso: vec![SpSsoDescriptor {
sso_base: make_sso_base(),
authn_requests_signed: None,
want_assertions_signed: None,
assertion_consumer_services: vec![], attribute_consuming_services: vec![],
}],
authn_authority: vec![],
attr_authority: vec![],
pdp: vec![],
},
organization: None,
contact_persons: vec![],
additional_metadata_locations: vec![],
};
let v = MetadataValidator::new();
let err = v.validate(&entity).unwrap_err();
assert!(err.to_string().contains("AssertionConsumerService"));
}
#[test]
fn test_resolve_default_indexed_endpoint() {
let eps = vec![
IndexedEndpoint::new(Endpoint::new("urn:binding:1", "https://example.com/1"), 0),
IndexedEndpoint::new_default(
Endpoint::new("urn:binding:2", "https://example.com/2"),
1,
),
];
let default = resolve_default_indexed_endpoint(&eps).unwrap();
assert_eq!(default.index, 1); }
#[test]
fn test_resolve_default_indexed_endpoint_none_set() {
let eps = vec![
IndexedEndpoint {
endpoint: Endpoint::new("urn:binding:1", "https://example.com/1"),
index: 2,
is_default: None,
},
IndexedEndpoint {
endpoint: Endpoint::new("urn:binding:2", "https://example.com/2"),
index: 0,
is_default: None,
},
];
let default = resolve_default_indexed_endpoint(&eps).unwrap();
assert_eq!(default.index, 2);
}
#[test]
fn test_resolve_endpoint_by_binding() {
let eps = vec![
Endpoint::new(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
"https://idp.example.com/sso/redirect",
),
Endpoint::new(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"https://idp.example.com/sso/post",
),
];
let ep =
resolve_endpoint_by_binding(&eps, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
.unwrap();
assert_eq!(ep.location, "https://idp.example.com/sso/post");
}
#[test]
fn test_resolve_endpoint_by_binding_not_found() {
let eps = vec![Endpoint::new(
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
"https://idp.example.com/sso",
)];
let ep = resolve_endpoint_by_binding(&eps, "urn:oasis:names:tc:SAML:2.0:bindings:SOAP");
assert!(ep.is_none());
}
}