use crate::authn::{SAML_NS, SAMLP_NS, saml_qname, samlp_qname};
use crate::authn_context::{AuthnContextComparison, RequestedAuthnContext};
use crate::binding::SsoResponseBinding;
use crate::error::Error;
use crate::nameid::NameIdFormat;
use crate::time::format_xs_datetime;
use crate::xml::emit::emit_document;
use crate::xml::parse::{Document, Element, Node, QName};
pub(crate) struct BuildAuthnRequest<'a> {
pub id: &'a str,
pub issue_instant: std::time::SystemTime,
pub issuer_entity_id: &'a str,
pub destination: &'a str,
pub force_authn: bool,
pub is_passive: bool,
pub acs_selection: AcsRequest<'a>,
pub protocol_binding: Option<SsoResponseBinding>,
pub requested_name_id_format: Option<NameIdFormat>,
pub requested_authn_context: Option<&'a RequestedAuthnContext>,
}
pub(crate) enum AcsRequest<'a> {
Default,
Index(u16),
Url(&'a str),
}
pub(crate) fn build_authn_request_element(input: &BuildAuthnRequest<'_>) -> Result<Element, Error> {
let mut builder = Element::build(samlp_qname("AuthnRequest"))
.with_namespace(Some("samlp".to_owned()), SAMLP_NS)
.with_namespace(Some("saml".to_owned()), SAML_NS)
.with_attribute(QName::new(None, "ID"), input.id.to_owned())
.with_attribute(QName::new(None, "Version"), "2.0")
.with_attribute(
QName::new(None, "IssueInstant"),
format_xs_datetime(input.issue_instant)?,
)
.with_attribute(
QName::new(None, "Destination"),
input.destination.to_owned(),
);
match &input.acs_selection {
AcsRequest::Default => {}
AcsRequest::Index(n) => {
builder = builder.with_attribute(
QName::new(None, "AssertionConsumerServiceIndex"),
n.to_string(),
);
}
AcsRequest::Url(u) => {
builder = builder.with_attribute(
QName::new(None, "AssertionConsumerServiceURL"),
(*u).to_owned(),
);
}
}
if let Some(binding) = input.protocol_binding {
builder = builder.with_attribute(
QName::new(None, "ProtocolBinding"),
binding.uri().to_owned(),
);
}
if input.force_authn {
builder = builder.with_attribute(QName::new(None, "ForceAuthn"), "true");
}
if input.is_passive {
builder = builder.with_attribute(QName::new(None, "IsPassive"), "true");
}
let issuer = Element::build(saml_qname("Issuer"))
.with_text(input.issuer_entity_id.to_owned())
.finish();
builder = builder.with_child(Node::Element(issuer));
if let Some(fmt) = &input.requested_name_id_format {
let mut pol = Element::build(samlp_qname("NameIDPolicy"))
.with_attribute(QName::new(None, "Format"), fmt.as_uri().to_owned());
if matches!(fmt, NameIdFormat::Persistent | NameIdFormat::EmailAddress) {
pol = pol.with_attribute(QName::new(None, "AllowCreate"), "true");
}
builder = builder.with_child(Node::Element(pol.finish()));
}
if let Some(rac) = input.requested_authn_context {
let mut rac_el = Element::build(samlp_qname("RequestedAuthnContext")).with_attribute(
QName::new(None, "Comparison"),
comparison_attr(&rac.comparison),
);
for class_ref in &rac.class_refs {
rac_el = rac_el.with_child(Node::Element(
Element::build(saml_qname("AuthnContextClassRef"))
.with_text(class_ref.as_uri().to_owned())
.finish(),
));
}
builder = builder.with_child(Node::Element(rac_el.finish()));
}
Ok(builder.finish())
}
pub(crate) fn build_authn_request_xml(input: &BuildAuthnRequest<'_>) -> Result<Vec<u8>, Error> {
let element = build_authn_request_element(input)?;
let doc = Document::new(element)?;
let xml = emit_document(&doc)?;
Ok(xml.into_bytes())
}
fn comparison_attr(c: &AuthnContextComparison) -> &'static str {
match c {
AuthnContextComparison::Exact => "exact",
AuthnContextComparison::Minimum => "minimum",
AuthnContextComparison::Maximum => "maximum",
AuthnContextComparison::Better => "better",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::authn_context::AuthnContextClassRef;
use std::time::{Duration, UNIX_EPOCH};
fn fixed_instant() -> std::time::SystemTime {
UNIX_EPOCH
.checked_add(Duration::from_secs(1_779_798_896))
.expect("UNIX_EPOCH + small duration fits in SystemTime")
}
fn minimal_input<'a>() -> BuildAuthnRequest<'a> {
BuildAuthnRequest {
id: "_abc123",
issue_instant: fixed_instant(),
issuer_entity_id: "https://sp.example.com/saml",
destination: "https://idp.example.com/sso",
force_authn: false,
is_passive: false,
acs_selection: AcsRequest::Default,
protocol_binding: None,
requested_name_id_format: None,
requested_authn_context: None,
}
}
fn emit_and_reparse(input: &BuildAuthnRequest<'_>) -> Document {
let xml = build_authn_request_xml(input).expect("build");
Document::parse(&xml).expect("re-parse")
}
#[test]
fn minimal_request_carries_required_attributes_and_issuer() {
let doc = emit_and_reparse(&minimal_input());
let root = doc.root();
assert_eq!(root.qname().namespace(), Some(SAMLP_NS));
assert_eq!(root.qname().local(), "AuthnRequest");
assert_eq!(root.attribute(None, "ID"), Some("_abc123"));
assert_eq!(root.attribute(None, "Version"), Some("2.0"));
assert_eq!(
root.attribute(None, "IssueInstant"),
Some("2026-05-26T12:34:56Z")
);
assert_eq!(
root.attribute(None, "Destination"),
Some("https://idp.example.com/sso")
);
assert_eq!(root.attribute(None, "ForceAuthn"), None);
assert_eq!(root.attribute(None, "IsPassive"), None);
assert_eq!(root.attribute(None, "AssertionConsumerServiceIndex"), None);
assert_eq!(root.attribute(None, "AssertionConsumerServiceURL"), None);
assert_eq!(root.attribute(None, "ProtocolBinding"), None);
let issuer = root.child_element(Some(SAML_NS), "Issuer").unwrap();
assert_eq!(issuer.text_content(), "https://sp.example.com/saml");
assert!(root.child_element(Some(SAMLP_NS), "NameIDPolicy").is_none());
assert!(
root.child_element(Some(SAMLP_NS), "RequestedAuthnContext")
.is_none()
);
}
#[test]
fn acs_index_and_protocol_binding_post() {
let mut input = minimal_input();
input.acs_selection = AcsRequest::Index(0);
input.protocol_binding = Some(SsoResponseBinding::HttpPost);
let doc = emit_and_reparse(&input);
let root = doc.root();
assert_eq!(
root.attribute(None, "AssertionConsumerServiceIndex"),
Some("0")
);
assert_eq!(root.attribute(None, "AssertionConsumerServiceURL"), None);
assert_eq!(
root.attribute(None, "ProtocolBinding"),
Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST")
);
}
#[test]
fn acs_url_variant() {
let mut input = minimal_input();
input.acs_selection = AcsRequest::Url("https://sp.example.com/acs/post");
let doc = emit_and_reparse(&input);
let root = doc.root();
assert_eq!(
root.attribute(None, "AssertionConsumerServiceURL"),
Some("https://sp.example.com/acs/post")
);
assert_eq!(root.attribute(None, "AssertionConsumerServiceIndex"), None);
}
#[test]
fn requested_authn_context_mfa_exact() {
let rac = RequestedAuthnContext {
class_refs: vec![AuthnContextClassRef::MultiFactorAuth],
comparison: AuthnContextComparison::Exact,
};
let mut input = minimal_input();
input.requested_authn_context = Some(&rac);
let doc = emit_and_reparse(&input);
let rac_el = doc
.root()
.child_element(Some(SAMLP_NS), "RequestedAuthnContext")
.expect("RequestedAuthnContext present");
assert_eq!(rac_el.attribute(None, "Comparison"), Some("exact"));
let cref = rac_el
.child_element(Some(SAML_NS), "AuthnContextClassRef")
.unwrap();
assert_eq!(
cref.text_content(),
"urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactorAuthentication"
);
}
#[test]
fn force_authn_and_is_passive_only_when_true() {
let mut input = minimal_input();
input.force_authn = true;
input.is_passive = true;
let doc = emit_and_reparse(&input);
assert_eq!(doc.root().attribute(None, "ForceAuthn"), Some("true"));
assert_eq!(doc.root().attribute(None, "IsPassive"), Some("true"));
}
#[test]
fn name_id_policy_persistent_emits_allow_create() {
let mut input = minimal_input();
input.requested_name_id_format = Some(NameIdFormat::Persistent);
let doc = emit_and_reparse(&input);
let pol = doc
.root()
.child_element(Some(SAMLP_NS), "NameIDPolicy")
.unwrap();
assert_eq!(
pol.attribute(None, "Format"),
Some("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")
);
assert_eq!(pol.attribute(None, "AllowCreate"), Some("true"));
}
#[test]
fn name_id_policy_email_emits_allow_create() {
let mut input = minimal_input();
input.requested_name_id_format = Some(NameIdFormat::EmailAddress);
let doc = emit_and_reparse(&input);
let pol = doc
.root()
.child_element(Some(SAMLP_NS), "NameIDPolicy")
.unwrap();
assert_eq!(pol.attribute(None, "AllowCreate"), Some("true"));
}
#[test]
fn name_id_policy_transient_omits_allow_create() {
let mut input = minimal_input();
input.requested_name_id_format = Some(NameIdFormat::Transient);
let doc = emit_and_reparse(&input);
let pol = doc
.root()
.child_element(Some(SAMLP_NS), "NameIDPolicy")
.unwrap();
assert_eq!(pol.attribute(None, "AllowCreate"), None);
}
#[test]
fn xml_is_well_formed_and_round_trips() {
let rac = RequestedAuthnContext {
class_refs: vec![
AuthnContextClassRef::PasswordProtectedTransport,
AuthnContextClassRef::MultiFactorAuth,
],
comparison: AuthnContextComparison::Minimum,
};
let mut input = minimal_input();
input.acs_selection = AcsRequest::Index(2);
input.protocol_binding = Some(SsoResponseBinding::HttpArtifact);
input.requested_name_id_format = Some(NameIdFormat::Persistent);
input.requested_authn_context = Some(&rac);
input.force_authn = true;
let xml = build_authn_request_xml(&input).expect("build");
let doc = Document::parse(&xml).expect("re-parse");
let root = doc.root();
assert_eq!(root.attribute(None, "ID"), Some("_abc123"));
assert_eq!(
root.attribute(None, "AssertionConsumerServiceIndex"),
Some("2")
);
assert_eq!(
root.attribute(None, "ProtocolBinding"),
Some("urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact")
);
assert_eq!(root.attribute(None, "ForceAuthn"), Some("true"));
let rac_el = root
.child_element(Some(SAMLP_NS), "RequestedAuthnContext")
.unwrap();
assert_eq!(rac_el.attribute(None, "Comparison"), Some("minimum"));
let class_refs: Vec<String> = rac_el
.all_child_elements(Some(SAML_NS), "AuthnContextClassRef")
.map(Element::text_content)
.collect();
assert_eq!(class_refs.len(), 2);
assert!(class_refs[0].ends_with("PasswordProtectedTransport"));
assert!(class_refs[1].ends_with("MultiFactorAuthentication"));
}
}