crates/nornir-auth-saml/tests/sp_interop.rs
//! **Interop: our verifier against an INDEPENDENT signer.**
//!
//! Every fixture here was signed by signxml, i.e. canonicalised by libxml2
//! (`tests/fixtures/gen.py`). A self-signed round trip proves only that our
//! C14N agrees with itself; these prove it agrees with the C library the
//! IdP-side toolkits are tested against — namespace push-down, attribute
//! ordering, escaping, `InclusiveNamespaces`, a default-namespaced Assertion,
//! ECDSA's raw `r||s`.
#![cfg(feature = "sp")]
use base64::Engine as _;
use nornir_auth_saml::sp::{self, c14n, Expect, SpError};
const FIX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
/// 2024-01-01T00:01:00Z — inside every fixture's window.
const NOW: i64 = 1_704_067_260;
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!("{FIX}/{name}")).unwrap_or_else(|e| panic!("{name}: {e}"))
}
fn cert(name: &str) -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode(fixture(name).trim())
.expect("a base64 certificate")
}
fn expect(certs: &[Vec<u8>]) -> Expect<'_> {
Expect {
idp_entity_id: "https://idp.example.com/",
sp_entity_id: "https://sp.example.com/metadata",
acs_url: "https://sp.example.com/acs",
request_id: Some("_req1"),
now_unix: NOW,
skew_secs: 120,
certificates_der: certs,
}
}
fn check(name: &str, certificate: &str) {
let certs = vec![cert(certificate)];
let a = sp::verify_response_xml(&fixture(name), &expect(&certs))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(a.assertion_id, "_assert1", "{name}");
assert_eq!(a.issuer, "https://idp.example.com/", "{name}");
assert_eq!(a.name_id, "alice@example.com", "{name}");
assert_eq!(
a.name_id_format.as_deref(),
Some("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"),
"{name}"
);
assert_eq!(a.session_index.as_deref(), Some("_sess1"), "{name}");
assert_eq!(
a.attributes["email"],
vec!["alice@example.com".to_owned()],
"{name}"
);
assert_eq!(
a.attributes["groups"],
vec!["eng".to_owned(), "ops & on-call".to_owned()],
"{name}"
);
// The bearer confirmation (00:05) is earlier than the Conditions (00:10).
assert_eq!(a.not_on_or_after_unix, 1_704_067_500, "{name}");
}
#[test]
fn an_assertion_signed_by_libxml2_verifies() {
check("a_assertion_signed.xml", "idp_rsa.cert.b64");
}
#[test]
fn a_response_signed_by_libxml2_verifies() {
check("b_response_signed.xml", "idp_rsa.cert.b64");
}
#[test]
fn both_signatures_by_libxml2_verify() {
check("b2_both_signed.xml", "idp_rsa.cert.b64");
}
#[test]
fn inclusive_namespaces_and_xs_typed_values_verify() {
check("c_inclusive_prefixes.xml", "idp_rsa.cert.b64");
}
#[test]
fn a_default_namespaced_assertion_verifies() {
check("d_default_namespace.xml", "idp_rsa.cert.b64");
}
#[test]
fn an_ecdsa_p256_signature_verifies() {
check("e_ecdsa_p256.xml", "idp_ec.cert.b64");
}
#[test]
fn the_base64_binding_verifies_with_line_breaks() {
let certs = vec![cert("idp_rsa.cert.b64")];
let raw = base64::engine::general_purpose::STANDARD.encode(fixture("a_assertion_signed.xml"));
let wrapped: String = raw
.as_bytes()
.chunks(76)
.map(|c| std::str::from_utf8(c).unwrap())
.collect::<Vec<_>>()
.join("\r\n");
let a = sp::verify_response_b64(&wrapped, &expect(&certs)).expect("the posted form verifies");
assert_eq!(a.name_id, "alice@example.com");
}
#[test]
fn a_libxml2_signature_does_not_verify_with_another_certificate() {
let other = vec![cert("idp_ec.cert.b64")];
let err =
sp::verify_response_xml(&fixture("a_assertion_signed.xml"), &expect(&other)).unwrap_err();
assert!(matches!(err, SpError::Signature(_)), "{err}");
}
/// Our exclusive C14N, byte for byte against lxml's, on subtrees chosen for the
/// rules that go wrong: push-down of a default namespace, `xmlns=""`, attribute
/// order across namespaces, escaping of `&<>\r` in text and `"\t\n\r<&` in
/// attributes, comments dropped, processing instructions kept, an inclusive
/// prefix rendered at the apex only.
#[test]
fn exclusive_c14n_matches_libxml2_byte_for_byte() {
let xml = fixture("c14n_doc.xml");
let doc = roxmltree::Document::parse(&xml).unwrap();
let mut n = 0;
for line in fixture("c14n_cases.txt")
.lines()
.filter(|l| !l.trim().is_empty())
{
let parts: Vec<&str> = line.split('|').collect();
let (name, ns, local, prefixes) = (parts[0], parts[1], parts[2], parts[3]);
let node = doc
.descendants()
.find(|n| {
n.is_element()
&& n.tag_name().name() == local
&& n.tag_name().namespace().unwrap_or("") == ns
})
.unwrap_or_else(|| panic!("{name}: no <{local}>"));
let prefixes: Vec<&str> = prefixes.split_whitespace().collect();
let ours = c14n::exclusive(&doc, node, &prefixes, None);
let theirs = fixture(&format!("c14n_{name}.expected"));
assert_eq!(ours, theirs, "case {name}");
n += 1;
}
assert_eq!(n, 8, "every case in the manifest ran");
}