crates/nornir-auth-saml/src/sp/mod.rs
//! # `sp` — a minimal, strict, pure-Rust SAML 2.0 Service Provider
//!
//! The HTTP-POST binding only: the IdP's browser-posted `SAMLResponse` is
//! verified here, and the SP's `AuthnRequest` goes out over HTTP-Redirect.
//!
//! ## Why not samael, why not a general XML-security library
//!
//! * **samael** links `xmlsec1`, `libxml2` and OpenSSL — C, built against
//! system packages. A guarded, static appliance cannot take that: gunnar's
//! dependency guard forbids `openssl` everywhere, and its server binary is
//! one static file. (This crate's samael path is deleted, A34-14.)
//! * **bergshamra** (pure Rust) is a broad, eight-month-old, single-maintainer
//! framework — PKCS#12, XML Encryption, HSMs, post-quantum algorithms — with
//! far more surface than an SP needs, all of which would have to be trusted.
//!
//! An SP needs a small part of XML-DSig: ONE enveloped signature, exclusive
//! C14N, RSA-SHA2 or ECDSA-P-256, verified against a certificate it was
//! configured with. That is what this module is, and it is checked byte for
//! byte against libxml2's canonical form (`tests/sp_interop.rs`, fixtures signed
//! by signxml).
//!
//! ## What a response must be (each rule has a test)
//!
//! * no DOCTYPE, at most [`MAX_RESPONSE_BYTES`]; a `samlp:Response`, Version
//! 2.0, `StatusCode` Success; IDs unique in the document;
//! * **exactly ONE `saml:Assertion` in the whole document**, a direct child of
//! the Response (no `EncryptedAssertion`);
//! * the Assertion covered by a VALID signature: a `ds:Signature` that is a
//! direct child of the Assertion (Reference `#<Assertion ID>`) or of the
//! Response (Reference `#<Response ID>`); every `ds:Signature` present must be
//! one of those two and must verify; the key is ONLY a configured
//! certificate, never the message's `KeyInfo`;
//! * every value — Issuer, NameID, conditions, attributes — read from the
//! verified Assertion node itself (signature wrapping cannot point the reader
//! elsewhere), text as ALL of an element's text (a comment cannot split it);
//! * Issuer = the IdP, Destination = the ACS URL (required on a signed
//! Response, optional when only the Assertion is signed), a bearer
//! `SubjectConfirmationData` with Recipient = the ACS URL, unexpired, and
//! `InResponseTo` = the request (when one was sent); Conditions in their
//! window and the SP in every `AudienceRestriction`; clock skew as given,
//! at most [`MAX_SKEW_SECS`].
//!
//! The caller keeps the replay cache: [`Assertion::assertion_id`] until
//! [`Assertion::not_on_or_after_unix`].
use std::collections::BTreeMap;
use std::fmt;
use base64::Engine as _;
use roxmltree::{Document, Node, ParsingOptions};
pub mod c14n;
pub(crate) mod dsig;
mod time;
pub use time::{format_instant, parse_instant};
pub(crate) const PROTOCOL: &str = "urn:oasis:names:tc:SAML:2.0:protocol";
pub(crate) const ASSERTION: &str = "urn:oasis:names:tc:SAML:2.0:assertion";
pub(crate) const METADATA: &str = "urn:oasis:names:tc:SAML:2.0:metadata";
pub(crate) const DSIG: &str = "http://www.w3.org/2000/09/xmldsig#";
const SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
const BEARER: &str = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
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";
/// The largest decoded `SAMLResponse` accepted.
pub const MAX_RESPONSE_BYTES: usize = 512 * 1024;
/// The largest node count a parsed response may have.
const MAX_NODES: u32 = 50_000;
/// The longest `RelayState` the binding allows.
pub const MAX_RELAY_STATE: usize = 80;
/// The most clock skew honoured, whatever [`Expect::skew_secs`] asks for
/// (A34-35; OIDC clamps to the same).
pub const MAX_SKEW_SECS: i64 = 300;
/// Why a response, a metadata document or a certificate was refused. The
/// words never carry a secret; they may name IDs, issuers and algorithms.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpError {
/// Not the shape a SAML message must have.
Malformed(String),
/// A signature is missing, does not cover the assertion, or does not verify.
Signature(String),
/// Signed, but not for this SP, not now, or not from this IdP.
Condition(String),
/// A shape or algorithm this SP deliberately does not accept.
Unsupported(String),
/// The IdP answered with a non-Success status. The words are fixed, by
/// the status code only: the unsigned StatusMessage is never carried.
Status(String),
}
impl fmt::Display for SpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SpError::Malformed(m) => write!(f, "malformed SAML: {m}"),
SpError::Signature(m) => write!(f, "SAML signature refused: {m}"),
SpError::Condition(m) => write!(f, "SAML assertion refused: {m}"),
SpError::Unsupported(m) => write!(f, "unsupported SAML: {m}"),
SpError::Status(m) => write!(f, "the IdP did not sign the user in: {m}"),
}
}
}
impl std::error::Error for SpError {}
/// What an IdP's metadata says, as far as an SP needs it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdpMetadata {
pub entity_id: String,
/// The `SingleSignOnService` for the HTTP-Redirect binding.
pub sso_redirect_url: String,
/// The signing certificates (DER), each with a supported key.
pub certificates_der: Vec<Vec<u8>>,
}
/// What the SP expects of one response.
#[derive(Debug, Clone)]
pub struct Expect<'a> {
pub idp_entity_id: &'a str,
/// The audience: the SP's entity id.
pub sp_entity_id: &'a str,
/// The Assertion Consumer Service URL: the Destination and the Recipient.
pub acs_url: &'a str,
/// The `AuthnRequest` ID this response must answer; `None` accepts an
/// IdP-initiated response (any or no `InResponseTo`).
pub request_id: Option<&'a str>,
pub now_unix: i64,
/// Clock skew allowed on every time check, clamped to
/// `0..=`[`MAX_SKEW_SECS`].
pub skew_secs: i64,
/// The IdP's signing certificates (DER).
pub certificates_der: &'a [Vec<u8>],
}
/// A verified assertion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assertion {
pub assertion_id: String,
pub issuer: String,
pub name_id: String,
pub name_id_format: Option<String>,
pub session_index: Option<String>,
/// Attribute `Name` → its values, in document order.
pub attributes: BTreeMap<String, Vec<String>>,
/// The earliest `NotOnOrAfter` that applies: keep the ID in a replay cache
/// until then.
pub not_on_or_after_unix: i64,
}
// ── small tree helpers ─────────────────────────────────────────────────────
pub(crate) fn child_elements<'a, 'i>(node: Node<'a, 'i>) -> impl Iterator<Item = Node<'a, 'i>> {
node.children().filter(Node::is_element)
}
fn is(node: &Node, ns: &str, local: &str) -> bool {
node.is_element() && node.tag_name().namespace() == Some(ns) && node.tag_name().name() == local
}
fn children<'a, 'i>(
node: Node<'a, 'i>,
ns: &'a str,
local: &'a str,
) -> impl Iterator<Item = Node<'a, 'i>> + 'a {
child_elements(node).filter(move |n| is(n, ns, local))
}
/// The one direct child `ns:local`; none or several is malformed.
fn only_child<'a, 'i>(
node: Node<'a, 'i>,
ns: &'a str,
local: &'a str,
) -> Result<Node<'a, 'i>, SpError> {
let mut it = children(node, ns, local);
let first = it.next().ok_or_else(|| {
SpError::Malformed(format!("<{}> has no <{local}>", node.tag_name().name()))
})?;
if it.next().is_some() {
return Err(SpError::Malformed(format!(
"<{}> has more than one <{local}>",
node.tag_name().name()
)));
}
Ok(first)
}
fn optional_child<'a, 'i>(
node: Node<'a, 'i>,
ns: &'a str,
local: &'a str,
) -> Result<Option<Node<'a, 'i>>, SpError> {
let mut it = children(node, ns, local);
let first = it.next();
if it.next().is_some() {
return Err(SpError::Malformed(format!(
"<{}> has more than one <{local}>",
node.tag_name().name()
)));
}
Ok(first)
}
/// ALL of an element's text, in document order — never just the first text
/// node, so `a@evil<!---->.example` reads as the signed `a@evil.example`.
pub(crate) fn text_of(node: Node) -> String {
node.descendants()
.filter(Node::is_text)
.filter_map(|n| n.text())
.collect()
}
fn refuse_doctype(xml: &str) -> Result<(), SpError> {
if xml.contains("<!DOCTYPE") || xml.contains("<!ENTITY") {
return Err(SpError::Malformed(
"a DOCTYPE or ENTITY declaration is refused".into(),
));
}
Ok(())
}
fn parse(xml: &str) -> Result<Document<'_>, SpError> {
refuse_doctype(xml)?;
Document::parse_with_options(
xml,
ParsingOptions {
allow_dtd: false,
nodes_limit: MAX_NODES,
},
)
.map_err(|e| SpError::Malformed(format!("not well-formed XML: {e}")))
}
fn instant(node: Node, attr: &str) -> Result<Option<i64>, SpError> {
node.attribute(attr).map(parse_instant).transpose()
}
// ── the response ───────────────────────────────────────────────────────────
/// Verify a browser-posted `SAMLResponse` (base64; whitespace tolerated).
pub fn verify_response_b64(saml_response_b64: &str, expect: &Expect) -> Result<Assertion, SpError> {
if saml_response_b64.len() > MAX_RESPONSE_BYTES * 4 / 3 + 4096 {
return Err(SpError::Malformed("the SAMLResponse is too large".into()));
}
let raw = dsig::b64(saml_response_b64, "the SAMLResponse")?;
if raw.len() > MAX_RESPONSE_BYTES {
return Err(SpError::Malformed("the SAMLResponse is too large".into()));
}
let xml = String::from_utf8(raw)
.map_err(|_| SpError::Malformed("the SAMLResponse is not UTF-8".into()))?;
verify_response_xml(&xml, expect)
}
/// Verify a `samlp:Response` document.
pub fn verify_response_xml(xml: &str, expect: &Expect) -> Result<Assertion, SpError> {
if xml.len() > MAX_RESPONSE_BYTES {
return Err(SpError::Malformed("the SAMLResponse is too large".into()));
}
let doc = parse(xml)?;
let response = doc.root_element();
if !is(&response, PROTOCOL, "Response") {
return Err(SpError::Malformed(format!(
"the document is <{}>, not a samlp:Response",
response.tag_name().name()
)));
}
if response.attribute("Version") != Some("2.0") {
return Err(SpError::Malformed(
"the Response is not SAML Version 2.0".into(),
));
}
// IDs are unique across the whole document: a duplicate is how a wrapped
// copy and the original would be told apart by a sloppier resolver.
let mut ids = std::collections::BTreeSet::new();
for node in doc.descendants().filter(Node::is_element) {
if let Some(id) = node.attribute("ID") {
if !ids.insert(id) {
return Err(SpError::Malformed(format!("the ID {id:?} appears twice")));
}
}
}
// The status, before anything else is looked at: a refusal is an answer.
let status = only_child(response, PROTOCOL, "Status")?;
let code = only_child(status, PROTOCOL, "StatusCode")?;
let value = code.attribute("Value").unwrap_or("");
if value != SUCCESS {
// The Status is not signed: its StatusMessage (and any URI that is no
// SAML status) is whatever the poster wrote, so only a known code's
// fixed name reaches the words an admin or a user sees (A34-36).
let second = children(code, PROTOCOL, "StatusCode")
.next()
.and_then(|c| c.attribute("Value"));
return Err(SpError::Status(status_words(value, second)));
}
if doc
.descendants()
.any(|n| is(&n, ASSERTION, "EncryptedAssertion"))
{
return Err(SpError::Unsupported(
"encrypted assertions are not accepted".into(),
));
}
let assertions: Vec<Node> = doc
.descendants()
.filter(|n| is(n, ASSERTION, "Assertion"))
.collect();
let assertion = match assertions.as_slice() {
[one] => *one,
[] => {
return Err(SpError::Malformed(
"the Response carries no Assertion".into(),
))
}
more => {
return Err(SpError::Malformed(format!(
"the document carries {} Assertions; exactly one is accepted",
more.len()
)))
}
};
if assertion.parent_element() != Some(response) {
return Err(SpError::Malformed(
"the Assertion is not a direct child of the Response".into(),
));
}
// Every signature in the document is one of exactly two, and verifies.
let signatures: Vec<Node> = doc
.descendants()
.filter(|n| is(n, DSIG, "Signature"))
.collect();
let mut on_response = 0;
let mut on_assertion = 0;
for sig in &signatures {
let parent = sig.parent_element();
if parent == Some(response) {
on_response += 1;
} else if parent == Some(assertion) {
on_assertion += 1;
} else {
return Err(SpError::Malformed(
"a ds:Signature sits somewhere other than on the Response or the Assertion".into(),
));
}
}
if on_response > 1 || on_assertion > 1 {
return Err(SpError::Malformed(
"an element carries more than one ds:Signature".into(),
));
}
if signatures.is_empty() {
return Err(SpError::Signature(
"neither the Response nor the Assertion is signed".into(),
));
}
for sig in &signatures {
let signed = sig.parent_element().expect("checked above");
dsig::verify_enveloped(&doc, signed, *sig, expect.certificates_der)?;
}
read_assertion(response, assertion, on_response == 1, expect)
}
/// The SAML 2.0 status codes (core 3.2.2.2): top level, then second level.
const TOP_STATUS: [&str; 3] = ["Requester", "Responder", "VersionMismatch"];
const SECOND_STATUS: [&str; 19] = [
"AuthnFailed",
"InvalidAttrNameOrValue",
"InvalidNameIDPolicy",
"NoAuthnContext",
"NoAvailableIDP",
"NoPassive",
"NoSupportedIDP",
"PartialLogout",
"ProxyCountExceeded",
"RequestDenied",
"RequestUnsupported",
"RequestVersionDeprecated",
"RequestVersionTooHigh",
"RequestVersionTooLow",
"ResourceNotRecognized",
"TooManyResponses",
"UnknownAttrProfile",
"UnknownPrincipal",
"UnsupportedBinding",
];
/// A status URI's fixed short name when it is one of `known`, else `None`.
fn known_status(value: &str, known: &[&'static str]) -> Option<&'static str> {
let name = value.strip_prefix("urn:oasis:names:tc:SAML:2.0:status:")?;
known.iter().copied().find(|k| *k == name)
}
/// The fixed words for a refusal status: the IdP refused, and its code.
fn status_words(top: &str, second: Option<&str>) -> String {
let Some(top) = known_status(top, &TOP_STATUS) else {
return "the IdP refused the sign-in with an unknown status".into();
};
match second.and_then(|s| known_status(s, &SECOND_STATUS)) {
Some(second) => format!("the IdP refused the sign-in (status {top}, {second})"),
None => format!("the IdP refused the sign-in (status {top})"),
}
}
/// Every value out of the VERIFIED assertion node.
fn read_assertion(
response: Node,
assertion: Node,
response_signed: bool,
expect: &Expect,
) -> Result<Assertion, SpError> {
let now = expect.now_unix;
let skew = expect.skew_secs.clamp(0, MAX_SKEW_SECS);
if assertion.attribute("Version") != Some("2.0") {
return Err(SpError::Malformed(
"the Assertion is not SAML Version 2.0".into(),
));
}
let assertion_id = assertion.attribute("ID").unwrap_or("").to_owned();
if assertion_id.is_empty() {
return Err(SpError::Malformed("the Assertion has no ID".into()));
}
let issuer = text_of(only_child(assertion, ASSERTION, "Issuer")?)
.trim()
.to_owned();
if issuer != expect.idp_entity_id {
return Err(SpError::Condition(format!(
"the assertion was issued by {issuer:?}, not by {:?}",
expect.idp_entity_id
)));
}
if let Some(outer) = optional_child(response, ASSERTION, "Issuer")? {
let outer = text_of(outer).trim().to_owned();
if outer != expect.idp_entity_id {
return Err(SpError::Condition(format!(
"the Response was issued by {outer:?}, not by the IdP"
)));
}
}
match response.attribute("Destination") {
Some(destination) if destination != expect.acs_url => {
return Err(SpError::Condition(format!(
"the Response is for {destination:?}, not for this SP's ACS URL"
)));
}
Some(_) => {}
// SAML profiles 4.1.4.5: a signed Response carries a Destination
// (A34-35); an Assertion signed alone may leave it out.
None if response_signed => {
return Err(SpError::Condition(
"the Response is signed but names no Destination".into(),
));
}
None => {}
}
// ── the subject ──────────────────────────────────────────────────────
let subject = only_child(assertion, ASSERTION, "Subject")?;
if child_elements(subject)
.any(|n| is(&n, ASSERTION, "EncryptedID") || is(&n, ASSERTION, "BaseID"))
{
return Err(SpError::Unsupported(
"only a plain NameID subject is accepted".into(),
));
}
let name_id_node = only_child(subject, ASSERTION, "NameID")?;
if child_elements(name_id_node).next().is_some() {
return Err(SpError::Malformed("the NameID holds elements".into()));
}
let name_id = text_of(name_id_node).trim().to_owned();
if name_id.is_empty() {
return Err(SpError::Malformed("the NameID is empty".into()));
}
let name_id_format = name_id_node.attribute("Format").map(str::to_owned);
let mut confirmed: Option<i64> = None;
let mut why = "the subject has no bearer SubjectConfirmation".to_owned();
for confirmation in children(subject, ASSERTION, "SubjectConfirmation") {
if confirmation.attribute("Method") != Some(BEARER) {
continue;
}
let Some(data) = optional_child(confirmation, ASSERTION, "SubjectConfirmationData")? else {
why = "the bearer confirmation carries no SubjectConfirmationData".into();
continue;
};
match confirm(data, expect, now, skew)? {
Ok(until) => {
confirmed = Some(until);
break;
}
Err(e) => why = e,
}
}
let Some(confirmed_until) = confirmed else {
return Err(SpError::Condition(why));
};
// ── the conditions ───────────────────────────────────────────────────
let conditions = only_child(assertion, ASSERTION, "Conditions")?;
if let Some(not_before) = instant(conditions, "NotBefore")? {
if not_before > now + skew {
return Err(SpError::Condition(
"the assertion is not valid yet (Conditions NotBefore)".into(),
));
}
}
let mut until = confirmed_until;
if let Some(not_after) = instant(conditions, "NotOnOrAfter")? {
if not_after <= now - skew {
return Err(SpError::Condition(
"the assertion has expired (Conditions NotOnOrAfter)".into(),
));
}
until = until.min(not_after);
}
let restrictions: Vec<Node> = children(conditions, ASSERTION, "AudienceRestriction").collect();
if restrictions.is_empty() {
return Err(SpError::Condition("the assertion names no audience".into()));
}
for restriction in restrictions {
if !children(restriction, ASSERTION, "Audience")
.any(|a| text_of(a).trim() == expect.sp_entity_id)
{
return Err(SpError::Condition(format!(
"the assertion is not for this SP: {:?} is not in its audience",
expect.sp_entity_id
)));
}
}
let session_index = children(assertion, ASSERTION, "AuthnStatement")
.next()
.and_then(|s| s.attribute("SessionIndex"))
.map(str::to_owned);
let mut attributes: BTreeMap<String, Vec<String>> = BTreeMap::new();
for statement in children(assertion, ASSERTION, "AttributeStatement") {
for attribute in children(statement, ASSERTION, "Attribute") {
let Some(name) = attribute.attribute("Name") else {
continue;
};
let values = attributes.entry(name.to_owned()).or_default();
for value in children(attribute, ASSERTION, "AttributeValue") {
values.push(text_of(value).trim().to_owned());
}
}
}
Ok(Assertion {
assertion_id,
issuer,
name_id,
name_id_format,
session_index,
attributes,
not_on_or_after_unix: until,
})
}
/// A `SubjectConfirmationData` time: a malformed one refuses the whole
/// response by name, as a malformed Conditions time does (A34-34).
fn confirmation_instant(data: Node, attr: &str) -> Result<Option<i64>, SpError> {
data.attribute(attr)
.map(|text| {
parse_instant(text).map_err(|e| {
SpError::Malformed(format!("the SubjectConfirmationData {attr}: {e}"))
})
})
.transpose()
}
/// One bearer `SubjectConfirmationData`: its NotOnOrAfter when it holds,
/// why not when it does not (another confirmation may still hold), and a
/// hard refusal when it is malformed.
fn confirm(
data: Node,
expect: &Expect,
now: i64,
skew: i64,
) -> Result<Result<i64, String>, SpError> {
let not_on_or_after = confirmation_instant(data, "NotOnOrAfter")?;
let not_before = confirmation_instant(data, "NotBefore")?;
let recipient = data.attribute("Recipient").unwrap_or("");
if recipient != expect.acs_url {
return Ok(Err(format!(
"the bearer confirmation is for {recipient:?}, not for this SP's ACS URL"
)));
}
let Some(until) = not_on_or_after else {
return Ok(Err("the bearer confirmation has no NotOnOrAfter".into()));
};
if until <= now - skew {
return Ok(Err(
"the assertion has expired (SubjectConfirmationData NotOnOrAfter)".into(),
));
}
if let Some(not_before) = not_before {
if not_before > now + skew {
return Ok(Err("the bearer confirmation is not valid yet".into()));
}
}
if let Some(request) = expect.request_id {
match data.attribute("InResponseTo") {
Some(answered) if answered == request => {}
Some(answered) => {
return Ok(Err(format!(
"the assertion answers {answered:?}, not this sign-in's request"
)))
}
None => {
return Ok(Err(
"the assertion answers no request, and this sign-in sent one".into(),
))
}
}
}
Ok(Ok(until))
}
// ── metadata, certificates, the request ────────────────────────────────────
/// An IdP's SAML metadata: its entity id, its HTTP-Redirect SSO endpoint and
/// its signing certificates. The metadata's own signature (if any) is not
/// checked: the operator vouches for it by pasting it, or it came over HTTPS.
pub fn parse_idp_metadata(xml: &str) -> Result<IdpMetadata, SpError> {
let doc = parse(xml)?;
let entities: Vec<Node> = doc
.descendants()
.filter(|n| is(n, METADATA, "EntityDescriptor"))
.filter(|n| children(*n, METADATA, "IDPSSODescriptor").next().is_some())
.collect();
let entity = match entities.as_slice() {
[one] => *one,
[] => {
return Err(SpError::Malformed(
"the metadata describes no IdP (IDPSSODescriptor)".into(),
))
}
_ => {
return Err(SpError::Malformed(
"the metadata describes several IdPs; give the metadata of the one".into(),
))
}
};
let entity_id = entity.attribute("entityID").unwrap_or("").trim().to_owned();
if entity_id.is_empty() {
return Err(SpError::Malformed(
"the IdP's EntityDescriptor has no entityID".into(),
));
}
let idp = children(entity, METADATA, "IDPSSODescriptor")
.next()
.expect("filtered above");
let mut certificates_der: Vec<Vec<u8>> = Vec::new();
for key in children(idp, METADATA, "KeyDescriptor") {
if !matches!(key.attribute("use"), None | Some("signing")) {
continue;
}
for cert in key.descendants().filter(|n| is(n, DSIG, "X509Certificate")) {
let der = dsig::b64(&text_of(cert), "an X509Certificate")?;
dsig::public_key(&der)?;
if !certificates_der.contains(&der) {
certificates_der.push(der);
}
}
}
if certificates_der.is_empty() {
return Err(SpError::Malformed(
"the metadata carries no signing certificate".into(),
));
}
let sso_redirect_url = children(idp, METADATA, "SingleSignOnService")
.find(|s| s.attribute("Binding") == Some(HTTP_REDIRECT))
.and_then(|s| s.attribute("Location"))
.map(|l| l.trim().to_owned())
.filter(|l| !l.is_empty())
.ok_or_else(|| {
SpError::Unsupported("the IdP offers no HTTP-Redirect SingleSignOnService".into())
})?;
Ok(IdpMetadata {
entity_id,
sso_redirect_url,
certificates_der,
})
}
/// A certificate an operator pasted — PEM, or bare base64 — as DER. It must
/// be X.509 with an RSA (≥ 2048 bits) or P-256 key.
pub fn parse_certificate_pem_or_b64(text: &str) -> Result<Vec<u8>, SpError> {
let body: String = text
.lines()
.filter(|l| !l.trim_start().starts_with("-----"))
.collect::<Vec<_>>()
.join("");
if body.trim().is_empty() {
return Err(SpError::Malformed("the certificate is empty".into()));
}
let der = dsig::b64(&body, "the certificate")?;
dsig::public_key(&der)?;
Ok(der)
}
pub(crate) fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
/// An `AuthnRequest` asking for an HTTP-POST response at `acs_url`. `id` must
/// be an XML NCName (start with a letter or `_`); it is what the response's
/// `InResponseTo` must answer.
pub fn authn_request_xml(
id: &str,
issue_instant: &str,
sp_entity_id: &str,
acs_url: &str,
idp_sso_url: &str,
) -> String {
format!(
"<samlp:AuthnRequest xmlns:samlp=\"{PROTOCOL}\" xmlns:saml=\"{ASSERTION}\" ID=\"{}\" Version=\"2.0\" \
IssueInstant=\"{}\" Destination=\"{}\" AssertionConsumerServiceURL=\"{}\" ProtocolBinding=\"{HTTP_POST}\">\
<saml:Issuer>{}</saml:Issuer><samlp:NameIDPolicy AllowCreate=\"true\"/></samlp:AuthnRequest>",
escape(id),
escape(issue_instant),
escape(idp_sso_url),
escape(acs_url),
escape(sp_entity_id),
)
}
/// Percent-encode everything but the unreserved characters.
fn percent(text: &str) -> String {
let mut out = String::with_capacity(text.len() * 3);
for b in text.bytes() {
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// The HTTP-Redirect URL that carries `authn_request_xml` to the IdP:
/// raw DEFLATE, base64, percent-encoded, with `RelayState` (at most 80 bytes).
pub fn redirect_url(
idp_sso_url: &str,
authn_request_xml: &str,
relay_state: &str,
) -> Result<String, SpError> {
use std::io::Write as _;
if !(idp_sso_url.starts_with("https://") || idp_sso_url.starts_with("http://"))
|| idp_sso_url.contains('#')
{
return Err(SpError::Malformed(
"the IdP's SSO URL must be an http(s) URL without a fragment".into(),
));
}
if relay_state.len() > MAX_RELAY_STATE {
return Err(SpError::Malformed(format!(
"RelayState is longer than {MAX_RELAY_STATE} bytes"
)));
}
let mut deflate =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
deflate
.write_all(authn_request_xml.as_bytes())
.map_err(|e| SpError::Malformed(format!("deflating the request: {e}")))?;
let packed = deflate
.finish()
.map_err(|e| SpError::Malformed(format!("deflating the request: {e}")))?;
let encoded = base64::engine::general_purpose::STANDARD.encode(packed);
let sep = if idp_sso_url.contains('?') { '&' } else { '?' };
let mut url = format!("{idp_sso_url}{sep}SAMLRequest={}", percent(&encoded));
if !relay_state.is_empty() {
url.push_str("&RelayState=");
url.push_str(&percent(relay_state));
}
Ok(url)
}
/// This SP's metadata, for the IdP's administrator: the entity id, and one
/// HTTP-POST Assertion Consumer Service; signed assertions wanted.
pub fn sp_metadata_xml(sp_entity_id: &str, acs_url: &str) -> String {
format!(
"<md:EntityDescriptor xmlns:md=\"{METADATA}\" entityID=\"{}\">\
<md:SPSSODescriptor AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"true\" \
protocolSupportEnumeration=\"{PROTOCOL}\">\
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>\
<md:AssertionConsumerService Binding=\"{HTTP_POST}\" Location=\"{}\" index=\"0\" isDefault=\"true\"/>\
</md:SPSSODescriptor></md:EntityDescriptor>",
escape(sp_entity_id),
escape(acs_url),
)
}