#![allow(clippy::doc_markdown)]
use core::fmt;
use crate::crypto::Sha256;
use crate::encoding::{base64_decode, base64_encode};
use crate::jwt::{AsymmetricAlgorithm, AsymmetricSigningKey, AsymmetricVerifyingKey};
use crate::xml::{XmlElement, parse_xml};
const NS_ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
const NS_PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
const NS_METADATA: &str = "urn:oasis:names:tc:SAML:2.0:metadata";
const NS_DSIG: &str = "http://www.w3.org/2000/09/xmldsig#";
const C14N_EXCLUSIVE: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
const TRANSFORM_ENVELOPED: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
const DIGEST_SHA256: &str = "http://www.w3.org/2001/04/xmlenc#sha256";
const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
const BINDING_POST: &str = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
fn canon_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'\r' => out.push_str("
"),
_ => out.push(c),
}
}
out
}
fn opt_attr(name: &str, value: &str) -> String {
if value.is_empty() {
String::new()
} else {
format!(r#"{name}="{}" "#, canon_attr(value))
}
}
fn canon_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'"' => out.push_str("""),
'\t' => out.push_str("	"),
'\n' => out.push_str("
"),
'\r' => out.push_str("
"),
_ => out.push(c),
}
}
out
}
fn signature_method(alg: AsymmetricAlgorithm) -> &'static str {
match alg {
AsymmetricAlgorithm::Es256 => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
AsymmetricAlgorithm::EdDsa => "http://www.w3.org/2001/04/xmldsig-more#eddsa",
_ => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
}
}
#[derive(Debug, Clone)]
pub struct AssertionParams<'a> {
pub id: &'a str,
pub issuer: &'a str,
pub issue_instant: &'a str,
pub subject_name_id: &'a str,
pub subject_name_id_format: &'a str,
pub in_response_to: &'a str,
pub recipient: &'a str,
pub not_before: &'a str,
pub not_on_or_after: &'a str,
pub audience: &'a str,
pub authn_instant: &'a str,
pub session_index: &'a str,
pub attributes: &'a [(&'a str, &'a [&'a str])],
}
#[derive(Debug, Clone)]
pub struct ResponseParams<'a> {
pub id: &'a str,
pub issue_instant: &'a str,
pub destination: &'a str,
pub in_response_to: &'a str,
pub issuer: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SamlIdpError {
Malformed(&'static str),
BadEncoding,
DigestMismatch,
SignatureInvalid,
}
impl fmt::Display for SamlIdpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed(what) => write!(f, "malformed signed document: {what}"),
Self::BadEncoding => write!(f, "invalid base64 in signature/digest"),
Self::DigestMismatch => write!(f, "assertion digest mismatch"),
Self::SignatureInvalid => write!(f, "signature verification failed"),
}
}
}
impl std::error::Error for SamlIdpError {}
fn assertion_canonical(p: &AssertionParams<'_>) -> String {
use core::fmt::Write as _;
let mut s = String::new();
let _ = write!(
s,
r#"<Assertion xmlns="{ns}" ID="{id}" IssueInstant="{ii}" Version="2.0"><Issuer>{issuer}</Issuer>"#,
ns = NS_ASSERTION,
id = canon_attr(p.id),
ii = canon_attr(p.issue_instant),
issuer = canon_text(p.issuer),
);
let _ = write!(
s,
concat!(
r#"<Subject><NameID Format="{fmt}">{nid}</NameID>"#,
r#"<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">"#,
r#"<SubjectConfirmationData {irt}NotOnOrAfter="{noa}" Recipient="{rcp}"></SubjectConfirmationData>"#,
r#"</SubjectConfirmation></Subject>"#,
),
fmt = canon_attr(p.subject_name_id_format),
nid = canon_text(p.subject_name_id),
irt = opt_attr("InResponseTo", p.in_response_to),
noa = canon_attr(p.not_on_or_after),
rcp = canon_attr(p.recipient),
);
let _ = write!(
s,
concat!(
r#"<Conditions NotBefore="{nb}" NotOnOrAfter="{noa}">"#,
r#"<AudienceRestriction><Audience>{aud}</Audience></AudienceRestriction>"#,
r#"</Conditions>"#,
),
nb = canon_attr(p.not_before),
noa = canon_attr(p.not_on_or_after),
aud = canon_text(p.audience),
);
let _ = write!(
s,
concat!(
r#"<AuthnStatement AuthnInstant="{ai}" SessionIndex="{si}">"#,
r#"<AuthnContext><AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</AuthnContextClassRef></AuthnContext>"#,
r#"</AuthnStatement>"#,
),
ai = canon_attr(p.authn_instant),
si = canon_attr(p.session_index),
);
if p.attributes.iter().any(|(_, v)| !v.is_empty()) {
s.push_str("<AttributeStatement>");
for (name, values) in p.attributes {
if values.is_empty() {
continue;
}
let _ = write!(s, r#"<Attribute Name="{n}">"#, n = canon_attr(name));
for value in *values {
let _ = write!(
s,
"<AttributeValue>{v}</AttributeValue>",
v = canon_text(value)
);
}
s.push_str("</Attribute>");
}
s.push_str("</AttributeStatement>");
}
s.push_str("</Assertion>");
s
}
fn signed_info_canonical(assertion_id: &str, digest_b64: &str, sig_method: &str) -> String {
format!(
concat!(
r#"<ds:SignedInfo xmlns:ds="{ns}">"#,
r#"<ds:CanonicalizationMethod Algorithm="{c14n}"></ds:CanonicalizationMethod>"#,
r#"<ds:SignatureMethod Algorithm="{sm}"></ds:SignatureMethod>"#,
r##"<ds:Reference URI="#{refid}">"##,
r#"<ds:Transforms>"#,
r#"<ds:Transform Algorithm="{env}"></ds:Transform>"#,
r#"<ds:Transform Algorithm="{c14n}"></ds:Transform>"#,
r#"</ds:Transforms>"#,
r#"<ds:DigestMethod Algorithm="{dm}"></ds:DigestMethod>"#,
r#"<ds:DigestValue>{dv}</ds:DigestValue>"#,
r#"</ds:Reference></ds:SignedInfo>"#,
),
ns = NS_DSIG,
c14n = C14N_EXCLUSIVE,
sm = sig_method,
refid = canon_attr(assertion_id),
env = TRANSFORM_ENVELOPED,
dm = DIGEST_SHA256,
dv = digest_b64,
)
}
#[must_use]
pub fn build_signed_response(
response: &ResponseParams<'_>,
assertion: &AssertionParams<'_>,
key: &AsymmetricSigningKey,
) -> String {
let sig_method = signature_method(key.algorithm());
let assertion_canon = assertion_canonical(assertion);
let digest = Sha256::digest(assertion_canon.as_bytes());
let digest_b64 = base64_encode(&digest);
let signed_info = signed_info_canonical(assertion.id, &digest_b64, sig_method);
let signature = key.sign(signed_info.as_bytes());
let signature_b64 = base64_encode(&signature);
let signature_block = format!(
concat!(
r#"<ds:Signature xmlns:ds="{ns}">"#,
"{signed_info}",
r#"<ds:SignatureValue>{sv}</ds:SignatureValue>"#,
r#"<ds:KeyInfo><ds:KeyName>{kid}</ds:KeyName></ds:KeyInfo>"#,
r#"</ds:Signature>"#,
),
ns = NS_DSIG,
signed_info = signed_info,
sv = signature_b64,
kid = canon_text(key.verifying_key().kid()),
);
let issuer_close = "</Issuer>";
let insert_at = assertion_canon
.find(issuer_close)
.map_or(assertion_canon.len(), |i| i + issuer_close.len());
let mut signed_assertion = String::with_capacity(assertion_canon.len() + signature_block.len());
signed_assertion.push_str(&assertion_canon[..insert_at]);
signed_assertion.push_str(&signature_block);
signed_assertion.push_str(&assertion_canon[insert_at..]);
format!(
concat!(
r#"<samlp:Response xmlns:samlp="{nsp}" Destination="{dest}" ID="{id}" "#,
r#"{irt}IssueInstant="{ii}" Version="2.0">"#,
r#"<Issuer xmlns="{nsa}">{issuer}</Issuer>"#,
r#"<samlp:Status><samlp:StatusCode Value="{success}"></samlp:StatusCode></samlp:Status>"#,
"{assertion}",
r#"</samlp:Response>"#,
),
nsp = NS_PROTOCOL,
dest = canon_attr(response.destination),
id = canon_attr(response.id),
irt = opt_attr("InResponseTo", response.in_response_to),
ii = canon_attr(response.issue_instant),
nsa = NS_ASSERTION,
issuer = canon_text(response.issuer),
success = STATUS_SUCCESS,
assertion = signed_assertion,
)
}
pub fn verify_signed_response(xml: &str, key: &AsymmetricVerifyingKey) -> Result<(), SamlIdpError> {
let (a_start, a_end) = span_of(xml, "<Assertion ", "</Assertion>")
.ok_or(SamlIdpError::Malformed("no Assertion"))?;
let assertion = &xml[a_start..a_end];
let (sig_start, sig_end) = span_of(assertion, "<ds:Signature ", "</ds:Signature>")
.ok_or(SamlIdpError::Malformed("no Signature"))?;
let signature_block = &assertion[sig_start..sig_end];
let signed_info = slice_between(signature_block, "<ds:SignedInfo ", "</ds:SignedInfo>")
.ok_or(SamlIdpError::Malformed("no SignedInfo"))?;
let digest_b64 = slice_between(signed_info, "<ds:DigestValue>", "</ds:DigestValue>")
.ok_or(SamlIdpError::Malformed("no DigestValue"))?;
let signature_b64 = slice_between(
signature_block,
"<ds:SignatureValue>",
"</ds:SignatureValue>",
)
.ok_or(SamlIdpError::Malformed("no SignatureValue"))?;
let mut enveloped = String::with_capacity(assertion.len());
enveloped.push_str(&assertion[..sig_start]);
enveloped.push_str(&assertion[sig_end..]);
let recomputed = base64_encode(&Sha256::digest(enveloped.as_bytes()));
if recomputed != digest_b64 {
return Err(SamlIdpError::DigestMismatch);
}
let (info_from, info_to) = span_of(signature_block, "<ds:SignedInfo ", "</ds:SignedInfo>")
.ok_or(SamlIdpError::Malformed("no SignedInfo"))?;
let signed_info_full = &signature_block[info_from..info_to];
let sig = base64_decode(signature_b64).map_err(|_| SamlIdpError::BadEncoding)?;
if key.verify(signed_info_full.as_bytes(), &sig) {
Ok(())
} else {
Err(SamlIdpError::SignatureInvalid)
}
}
fn span_of(haystack: &str, open: &str, close: &str) -> Option<(usize, usize)> {
let start = haystack.find(open)?;
let after = start + open.len();
let end = after + haystack[after..].find(close)? + close.len();
Some((start, end))
}
fn slice_between<'a>(haystack: &'a str, open: &str, close: &str) -> Option<&'a str> {
let start = haystack.find(open)? + open.len();
let rest = &haystack[start..];
let end = rest.find(close)?;
Some(&rest[..end])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthnRequest {
pub id: String,
pub issuer: String,
pub acs_url: Option<String>,
pub destination: Option<String>,
}
pub fn parse_authn_request(xml: &str) -> Result<AuthnRequest, SamlIdpError> {
let root = parse_xml(xml).map_err(|_| SamlIdpError::Malformed("unparseable AuthnRequest"))?;
if root.namespace().is_some_and(|ns| ns != NS_PROTOCOL) || root.name() != "AuthnRequest" {
return Err(SamlIdpError::Malformed("root is not AuthnRequest"));
}
if root.attribute("Version") != Some("2.0") {
return Err(SamlIdpError::Malformed("AuthnRequest Version is not 2.0"));
}
let id = root
.attribute("ID")
.ok_or(SamlIdpError::Malformed("AuthnRequest has no ID"))?
.to_string();
let issuer = root
.children()
.iter()
.find(|c| c.name() == "Issuer" && c.namespace().is_none_or(|ns| ns == NS_ASSERTION))
.and_then(XmlElement::text_content)
.ok_or(SamlIdpError::Malformed("AuthnRequest has no Issuer"))?
.to_string();
Ok(AuthnRequest {
id,
issuer,
acs_url: root
.attribute("AssertionConsumerServiceURL")
.map(str::to_string),
destination: root.attribute("Destination").map(str::to_string),
})
}
#[must_use]
pub fn generate_idp_metadata(entity_id: &str, sso_url: &str, signing_key_id: &str) -> String {
format!(
concat!(
r#"<md:EntityDescriptor xmlns:md="{nsm}" entityID="{eid}">"#,
r#"<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="{nsp}">"#,
r#"<md:KeyDescriptor use="signing">"#,
r#"<ds:KeyInfo xmlns:ds="{nsd}"><ds:KeyName>{kid}</ds:KeyName></ds:KeyInfo>"#,
r#"</md:KeyDescriptor>"#,
r#"<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>"#,
r#"<md:SingleSignOnService Binding="{post}" Location="{sso}"></md:SingleSignOnService>"#,
r#"</md:IDPSSODescriptor></md:EntityDescriptor>"#,
),
nsm = NS_METADATA,
eid = canon_attr(entity_id),
nsp = NS_PROTOCOL,
nsd = NS_DSIG,
kid = canon_text(signing_key_id),
post = BINDING_POST,
sso = canon_attr(sso_url),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jwt::{AsymmetricAlgorithm, AsymmetricSigningKey};
const TEST_ATTRS: &[(&str, &[&str])] =
&[("email", &["frodo@example.com"]), ("role", &["user"])];
fn params<'a>() -> (ResponseParams<'a>, AssertionParams<'a>) {
(
ResponseParams {
id: "_resp1",
issue_instant: "2024-01-01T00:00:00Z",
destination: "https://sp.example.com/acs",
in_response_to: "_req1",
issuer: "https://idp.example.com",
},
AssertionParams {
id: "_assert1",
issuer: "https://idp.example.com",
issue_instant: "2024-01-01T00:00:00Z",
subject_name_id: "frodo@example.com",
subject_name_id_format: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
in_response_to: "_req1",
recipient: "https://sp.example.com/acs",
not_before: "2024-01-01T00:00:00Z",
not_on_or_after: "2024-01-01T01:00:00Z",
audience: "https://sp.example.com",
authn_instant: "2024-01-01T00:00:00Z",
session_index: "_sess1",
attributes: TEST_ATTRS,
},
)
}
#[test]
fn signed_response_round_trips_es256() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256).unwrap();
let (resp, assertion) = params();
let xml = build_signed_response(&resp, &assertion, &key);
assert!(
crate::xml::parse_xml(&xml).is_ok(),
"produced XML must parse"
);
assert!(xml.contains("urn:oasis:names:tc:SAML:2.0:status:Success"));
assert!(xml.contains("frodo@example.com"));
verify_signed_response(&xml, key.verifying_key()).unwrap();
}
#[test]
fn signed_response_round_trips_eddsa() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap();
let (resp, assertion) = params();
let xml = build_signed_response(&resp, &assertion, &key);
verify_signed_response(&xml, key.verifying_key()).unwrap();
}
#[test]
fn idp_initiated_omits_empty_in_response_to() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap();
let (mut resp, mut assertion) = params();
resp.in_response_to = "";
assertion.in_response_to = "";
let xml = build_signed_response(&resp, &assertion, &key);
assert!(
!xml.contains(r#"InResponseTo="""#),
"must not emit an empty InResponseTo: {xml}"
);
assert!(
!xml.contains("InResponseTo"),
"IdP-initiated response must omit InResponseTo entirely"
);
verify_signed_response(&xml, key.verifying_key()).unwrap();
}
#[test]
fn solicited_keeps_in_response_to() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::EdDsa).unwrap();
let (resp, assertion) = params();
let xml = build_signed_response(&resp, &assertion, &key);
assert!(xml.contains(r#"InResponseTo="_req1""#));
verify_signed_response(&xml, key.verifying_key()).unwrap();
}
#[test]
fn tampered_assertion_fails_digest() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256).unwrap();
let (resp, assertion) = params();
let xml = build_signed_response(&resp, &assertion, &key);
let tampered = xml.replace("frodo@example.com", "evil@example.com");
assert_ne!(tampered, xml);
assert_eq!(
verify_signed_response(&tampered, key.verifying_key()),
Err(SamlIdpError::DigestMismatch)
);
}
#[test]
fn wrong_key_fails_signature() {
let key = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256).unwrap();
let other = AsymmetricSigningKey::generate(AsymmetricAlgorithm::Es256).unwrap();
let (resp, assertion) = params();
let xml = build_signed_response(&resp, &assertion, &key);
assert_eq!(
verify_signed_response(&xml, other.verifying_key()),
Err(SamlIdpError::SignatureInvalid)
);
}
#[test]
fn canonical_output_is_deterministic() {
let (_r, assertion) = params();
assert_eq!(
assertion_canonical(&assertion),
assertion_canonical(&assertion)
);
}
#[test]
fn parse_authn_request_extracts_fields() {
let xml = concat!(
r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" "#,
r#"xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req42" Version="2.0" "#,
r#"Destination="https://idp.example.com/sso" "#,
r#"AssertionConsumerServiceURL="https://sp.example.com/acs">"#,
r#"<saml:Issuer>https://sp.example.com</saml:Issuer>"#,
r#"</samlp:AuthnRequest>"#,
);
let req = parse_authn_request(xml).unwrap();
assert_eq!(req.id, "_req42");
assert_eq!(req.issuer, "https://sp.example.com");
assert_eq!(req.acs_url.as_deref(), Some("https://sp.example.com/acs"));
assert_eq!(
req.destination.as_deref(),
Some("https://idp.example.com/sso")
);
}
#[test]
fn parse_authn_request_rejects_non_2_0_version() {
let missing = concat!(
r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" "#,
r#"xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req42">"#,
r#"<saml:Issuer>https://sp.example.com</saml:Issuer>"#,
r#"</samlp:AuthnRequest>"#,
);
assert!(matches!(
parse_authn_request(missing),
Err(SamlIdpError::Malformed(_))
));
let wrong = concat!(
r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" "#,
r#"xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_req42" Version="1.1">"#,
r#"<saml:Issuer>https://sp.example.com</saml:Issuer>"#,
r#"</samlp:AuthnRequest>"#,
);
assert!(matches!(
parse_authn_request(wrong),
Err(SamlIdpError::Malformed(_))
));
}
#[test]
fn parse_authn_request_rejects_foreign_namespaced_issuer() {
let xml = concat!(
r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" "#,
r#"xmlns:evil="urn:evil" ID="_req42" Version="2.0">"#,
r#"<evil:Issuer>https://attacker.example.com</evil:Issuer>"#,
r#"</samlp:AuthnRequest>"#,
);
assert!(matches!(
parse_authn_request(xml),
Err(SamlIdpError::Malformed(_))
));
}
#[test]
fn idp_metadata_contains_bindings_and_key() {
let md = generate_idp_metadata(
"https://idp.example.com",
"https://idp.example.com/sso",
"key-1",
);
assert!(crate::xml::parse_xml(&md).is_ok());
assert!(md.contains("IDPSSODescriptor"));
assert!(!md.contains("HTTP-Redirect"));
assert!(md.contains("HTTP-POST"));
assert!(md.contains("key-1"));
}
}