use std::time::SystemTime;
use crate::authn::request_parse::RawParsedAuthnRequest;
use crate::authn_context::RequestedAuthnContext;
use crate::binding::{SsoResponseBinding, SsoResponseEndpoint};
use crate::descriptor::sp::SpDescriptor;
use crate::error::Error;
use crate::nameid::NameIdFormat;
#[derive(Debug, Clone)]
pub struct ParsedAuthnRequest {
pub id: String,
pub issuer: String,
pub issue_instant: SystemTime,
pub destination: Option<String>,
pub assertion_consumer_service: SsoResponseEndpoint,
pub protocol_binding: Option<SsoResponseBinding>,
pub assertion_consumer_service_selection: AcsSelection,
pub force_authn: bool,
pub is_passive: bool,
pub requested_name_id_format: Option<NameIdFormat>,
pub requested_authn_context: Option<RequestedAuthnContext>,
pub relay_state: Option<String>,
}
#[derive(Debug, Clone)]
pub enum AcsSelection {
Index(u16),
Url(String),
Default,
}
pub(crate) fn validate_authn_request(
raw: RawParsedAuthnRequest,
sp: &SpDescriptor,
expected_destination: &str,
sp_sso_endpoint_urls: &[String],
) -> Result<ParsedAuthnRequest, Error> {
if raw.issuer != sp.entity_id {
return Err(Error::IssuerMismatch {
expected: sp.entity_id.clone(),
got: Some(raw.issuer),
});
}
if !sp_sso_endpoint_urls
.iter()
.any(|u| u == expected_destination)
{
return Err(Error::InvalidConfiguration {
reason: "expected_destination is not a registered SSO endpoint",
});
}
if let Some(dest) = raw.destination.as_deref()
&& dest != expected_destination
{
return Err(Error::DestinationMismatch);
}
let unregistered = || Error::UnregisteredAcs {
entity_id: sp.entity_id.clone(),
};
let (resolved, selection): (SsoResponseEndpoint, AcsSelection) =
if let Some(index) = raw.assertion_consumer_service_index {
let endpoint = sp
.acs_endpoint_by_index(index)
.cloned()
.ok_or_else(unregistered)?;
(endpoint, AcsSelection::Index(index))
} else if let Some(url) = raw.assertion_consumer_service_url.as_deref() {
let endpoint = sp
.acs_endpoint_by_url(url)
.cloned()
.ok_or_else(unregistered)?;
(endpoint, AcsSelection::Url(url.to_owned()))
} else {
let endpoint = sp.default_acs().cloned().ok_or_else(unregistered)?;
(endpoint, AcsSelection::Default)
};
if let Some(requested) = raw.protocol_binding
&& requested != resolved.binding
{
return Err(Error::IllegalResponseBinding {
requested: requested.as_binding(),
});
}
Ok(ParsedAuthnRequest {
id: raw.id,
issuer: raw.issuer,
issue_instant: raw.issue_instant,
destination: raw.destination,
assertion_consumer_service: resolved,
protocol_binding: raw.protocol_binding,
assertion_consumer_service_selection: selection,
force_authn: raw.force_authn,
is_passive: raw.is_passive,
requested_name_id_format: raw.requested_name_id_format,
requested_authn_context: raw.requested_authn_context,
relay_state: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authn::request_parse::parse_authn_request;
use crate::binding::SsoResponseBinding;
use crate::xml::parse::Document;
fn sp_with_acs_layout(acs_xml: &str) -> SpDescriptor {
let xml = format!(
r#"<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://sp.example.com/saml">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
{acs_xml}
</md:SPSSODescriptor>
</md:EntityDescriptor>"#
);
SpDescriptor::from_metadata_xml(xml.as_bytes()).unwrap()
}
fn default_sp() -> SpDescriptor {
sp_with_acs_layout(
r#"<md:AssertionConsumerService index="0" isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://sp.example.com/acs/post"/>
<md:AssertionConsumerService index="1"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"
Location="https://sp.example.com/acs/artifact"/>"#,
)
}
fn sso_urls() -> Vec<String> {
vec!["https://idp.example.com/sso".to_string()]
}
fn raw_request(xml: &str) -> RawParsedAuthnRequest {
let doc = Document::parse(xml.as_bytes()).unwrap();
parse_authn_request(&doc).unwrap().0
}
const REQ_INDEX_0_POST: &str = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso"
AssertionConsumerServiceIndex="0"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
#[test]
fn happy_path_index_resolution() {
let sp = default_sp();
let parsed = validate_authn_request(
raw_request(REQ_INDEX_0_POST),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.expect("validate ok");
assert_eq!(parsed.id, "_id");
assert_eq!(parsed.issuer, "https://sp.example.com/saml");
assert_eq!(
parsed.assertion_consumer_service.url,
"https://sp.example.com/acs/post"
);
assert_eq!(
parsed.assertion_consumer_service.binding,
SsoResponseBinding::HttpPost
);
match parsed.assertion_consumer_service_selection {
AcsSelection::Index(0) => {}
other => panic!("expected Index(0), got {other:?}"),
}
assert_eq!(parsed.protocol_binding, Some(SsoResponseBinding::HttpPost));
assert!(parsed.relay_state.is_none());
}
#[test]
fn happy_path_default_when_no_acs_attrs() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let parsed = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.expect("validate ok");
assert!(matches!(
parsed.assertion_consumer_service_selection,
AcsSelection::Default
));
assert_eq!(
parsed.assertion_consumer_service.url,
"https://sp.example.com/acs/post"
);
}
#[test]
fn happy_path_url_resolution() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso"
AssertionConsumerServiceURL="https://sp.example.com/acs/artifact">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let parsed = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.expect("validate ok");
assert!(matches!(
parsed.assertion_consumer_service_selection,
AcsSelection::Url(ref u) if u == "https://sp.example.com/acs/artifact"
));
assert_eq!(
parsed.assertion_consumer_service.binding,
SsoResponseBinding::HttpArtifact
);
}
#[test]
fn issuer_mismatch_is_structured_error() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso"
AssertionConsumerServiceIndex="0">
<saml:Issuer>https://other-sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let err = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.unwrap_err();
match err {
Error::IssuerMismatch { expected, got } => {
assert_eq!(expected, "https://sp.example.com/saml");
assert_eq!(got.as_deref(), Some("https://other-sp.example.com/saml"));
}
other => panic!("expected IssuerMismatch, got {other:?}"),
}
}
#[test]
fn destination_not_in_sso_list_is_invalid_configuration() {
let sp = default_sp();
let err = validate_authn_request(
raw_request(REQ_INDEX_0_POST),
&sp,
"https://idp.example.com/sso", &[],
)
.unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert_eq!(
reason,
"expected_destination is not a registered SSO endpoint"
);
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn destination_mismatch_with_request_attr() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso-other"
AssertionConsumerServiceIndex="0">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let err = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.unwrap_err();
match err {
Error::DestinationMismatch => {}
other => panic!("expected DestinationMismatch, got {other:?}"),
}
}
#[test]
fn destination_absent_on_request_is_accepted() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
AssertionConsumerServiceIndex="0">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let parsed = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.expect("validate ok");
assert!(parsed.destination.is_none());
}
#[test]
fn acs_index_unknown_is_unregistered() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso"
AssertionConsumerServiceIndex="42">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let err = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.unwrap_err();
match err {
Error::UnregisteredAcs { entity_id } => {
assert_eq!(entity_id, "https://sp.example.com/saml");
}
other => panic!("expected UnregisteredAcs, got {other:?}"),
}
}
#[test]
fn acs_url_unknown_is_unregistered() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso"
AssertionConsumerServiceURL="https://attacker.example.com/exfil">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let err = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.unwrap_err();
match err {
Error::UnregisteredAcs { entity_id } => {
assert_eq!(entity_id, "https://sp.example.com/saml");
}
other => panic!("expected UnregisteredAcs, got {other:?}"),
}
}
#[test]
fn protocol_binding_mismatch_against_resolved_acs() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso"
AssertionConsumerServiceIndex="0"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let err = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.unwrap_err();
match err {
Error::IllegalResponseBinding { requested } => {
assert_eq!(requested, crate::binding::Binding::HttpArtifact);
}
other => panic!("expected IllegalResponseBinding, got {other:?}"),
}
}
#[test]
fn no_default_acs_and_no_attrs_is_unregistered() {
let sp = sp_with_acs_layout("");
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let err = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.unwrap_err();
match err {
Error::UnregisteredAcs { entity_id } => {
assert_eq!(entity_id, "https://sp.example.com/saml");
}
other => panic!("expected UnregisteredAcs, got {other:?}"),
}
}
#[test]
fn protocol_binding_none_uses_resolved_acs_binding() {
let xml = r#"<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_id" Version="2.0"
IssueInstant="2026-05-26T12:34:56Z"
Destination="https://idp.example.com/sso"
AssertionConsumerServiceIndex="1">
<saml:Issuer>https://sp.example.com/saml</saml:Issuer>
</samlp:AuthnRequest>"#;
let sp = default_sp();
let parsed = validate_authn_request(
raw_request(xml),
&sp,
"https://idp.example.com/sso",
&sso_urls(),
)
.unwrap();
assert!(parsed.protocol_binding.is_none());
assert_eq!(
parsed.assertion_consumer_service.binding,
SsoResponseBinding::HttpArtifact
);
}
}