crates/nornir-identity-sso/src/saml.rs
//! **SAML 2.0** (moved from Gunnar's `sso/saml.rs`, A34 M-4): an AuthnRequest
//! out (HTTP-Redirect), the signed Response back (HTTP-POST to the console's
//! ACS, relayed), verified by `nornir_auth_saml::sp` — pure Rust, because
//! samael needs xmlsec1/libxml2/OpenSSL. Only SP-initiated sign-ins: a response
//! must answer an AuthnRequest this listener sent (`InResponseTo`), and each
//! assertion is consumed once.
use nornir_auth_saml::sp;
use crate::egress::Egress;
use crate::state::{Flow, SsoState, FLOW_TTL_MS, MAX_IDP_BODY, SKEW_SECS};
/// An account's SAML provider, as the product stored it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SamlProvider {
/// The account whose people sign in through it.
pub account: String,
/// The console the ACS and the SP entity id hang off.
pub console_origin: String,
/// The IdP's entity id (the assertion's issuer).
pub idp_entity_id: String,
/// Where the AuthnRequest goes (HTTP-Redirect binding).
pub idp_sso_url: String,
/// The IdP's signing certificates (DER): the ONLY keys a response is
/// verified with — a key in the message is never trusted.
pub certificates_der: Vec<Vec<u8>>,
/// The attribute that names the person (the NameID when empty).
pub username_attribute: String,
/// The attribute shown as the person's name.
pub display_name_attribute: String,
}
impl SamlProvider {
fn checked(&self) -> Result<(), String> {
if self.idp_entity_id.trim().is_empty()
|| self.idp_sso_url.trim().is_empty()
|| self.certificates_der.is_empty()
{
return Err(format!(
"{}'s SAML provider has no IdP entity id, sign-on URL or certificate",
self.account
));
}
Ok(())
}
}
/// What an admin typed for a SAML provider: metadata (pasted, or an https URL
/// to fetch), and the fields set by hand, which win.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SamlInput {
pub metadata_xml: String,
pub metadata_url: String,
pub idp_entity_id: String,
pub idp_sso_url: String,
/// PEM or base64 DER, one per certificate.
pub certificates: Vec<String>,
pub username_attribute: String,
pub display_name_attribute: String,
}
/// The settings to store, from [`resolve_input`].
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SamlResolved {
pub metadata_url: String,
pub idp_entity_id: String,
pub idp_sso_url: String,
pub certificates_der: Vec<Vec<u8>>,
pub username_attribute: String,
pub display_name_attribute: String,
}
/// What a verified assertion says about the person.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verified {
pub issuer: String,
pub subject: String,
pub user_name: String,
pub display_name: String,
pub email: String,
pub assertion_id: String,
pub not_on_or_after_unix: i64,
}
const TRANSIENT: &str = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";
/// **Fill an admin's SAML input**: metadata (pasted, or fetched from its https
/// URL through `egress`) gives the entity id, the sign-on URL and the
/// certificates; fields set by hand win; every certificate is parsed (PEM or
/// base64). Blocking when a URL is fetched.
pub fn resolve_input(
state: &SsoState,
egress: Option<&dyn Egress>,
input: &SamlInput,
) -> Result<SamlResolved, String> {
let mut out = SamlResolved {
metadata_url: input.metadata_url.trim().to_owned(),
idp_entity_id: input.idp_entity_id.trim().to_owned(),
idp_sso_url: input.idp_sso_url.trim().to_owned(),
certificates_der: Vec::new(),
username_attribute: input.username_attribute.trim().to_owned(),
display_name_attribute: input.display_name_attribute.trim().to_owned(),
};
let mut metadata_xml = input.metadata_xml.trim().to_owned();
if metadata_xml.is_empty() && !out.metadata_url.is_empty() {
crate::guard::url_allowed_in(
&out.metadata_url,
state.allow_loopback_http(),
&state.private_nets(),
)?;
let egress =
egress.ok_or("this server reaches no identity provider (no egress is wired)")?;
let (status, body) = egress
.get(&out.metadata_url)
.map_err(|e| format!("the IdP's metadata: {e}"))?;
if !(200..300).contains(&status) || body.len() > MAX_IDP_BODY {
return Err(format!(
"the IdP's metadata answered HTTP {status} ({} bytes)",
body.len()
));
}
metadata_xml = body;
}
let mut certs: Vec<Vec<u8>> = Vec::new();
if !metadata_xml.is_empty() {
let md = sp::parse_idp_metadata(&metadata_xml)
.map_err(|e| format!("the IdP's metadata: {e}"))?;
if out.idp_entity_id.is_empty() {
out.idp_entity_id = md.entity_id;
}
if out.idp_sso_url.is_empty() {
out.idp_sso_url = md.sso_redirect_url;
}
certs = md.certificates_der;
}
if !input.certificates.is_empty() {
certs = input
.certificates
.iter()
.filter(|c| !c.trim().is_empty())
.map(|c| {
sp::parse_certificate_pem_or_b64(c).map_err(|e| format!("an IdP certificate: {e}"))
})
.collect::<Result<_, _>>()?;
}
if out.idp_entity_id.is_empty() {
return Err("the IdP's entity id is required (paste its metadata, or name it)".to_owned());
}
if !(out.idp_sso_url.starts_with("https://")
|| state.allow_loopback_http() && out.idp_sso_url.starts_with("http://"))
{
return Err(format!(
"the IdP's sign-on URL {:?} is not an https URL",
out.idp_sso_url
));
}
if certs.is_empty() {
return Err("the IdP's signing certificate is required: a response is verified with it and nothing else".to_owned());
}
out.certificates_der = certs;
Ok(out)
}
/// **The redirect**: an AuthnRequest (ID `_<hex>`) deflated onto the IdP's
/// sign-on URL with the state as RelayState. Returns (URL, state, flow).
pub fn begin(provider: &SamlProvider, now: i64) -> Result<(String, String, Flow), String> {
provider.checked()?;
let state_token = state_token(&crate::random_bytes(32));
let request_id = format!("_{}", crate::hex(&crate::random_bytes(20)));
let sp_entity_id = crate::paths::sp_entity_id(&provider.console_origin, &provider.account);
let acs = crate::paths::acs_url(&provider.console_origin);
let xml = sp::authn_request_xml(
&request_id,
&sp::format_instant(now / 1000),
&sp_entity_id,
&acs,
provider.idp_sso_url.trim(),
);
let url = sp::redirect_url(provider.idp_sso_url.trim(), &xml, &state_token)
.map_err(|e| e.to_string())?;
let flow = Flow {
account: provider.account.clone(),
saml: true,
console_origin: provider.console_origin.clone(),
expires_ms: now + FLOW_TTL_MS,
nonce: String::new(),
verifier: String::new(),
redirect_uri: acs,
request_id,
client: String::new(),
};
Ok((url, state_token, flow))
}
/// A state token: base64url (no padding) of fresh random bytes — the form
/// `nornir_auth_oidc::rp::random_token` makes, without linking the OIDC crate.
fn state_token(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn first(values: Option<&Vec<String>>) -> Option<String> {
values
.and_then(|v| v.first())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// **The ACS**: the response verified (signature over the one assertion with
/// the configured certificates only, issuer, audience, destination,
/// recipient, InResponseTo == this flow's request, the time window ± skew),
/// its assertion consumed once, the person read out of it.
pub fn finish(
state: &SsoState,
provider: &SamlProvider,
flow: &Flow,
saml_response: &str,
now: i64,
) -> Result<Verified, String> {
provider.checked()?;
let sp_entity_id = crate::paths::sp_entity_id(&provider.console_origin, &provider.account);
let acs = crate::paths::acs_url(&provider.console_origin);
let assertion = sp::verify_response_b64(
saml_response,
&sp::Expect {
idp_entity_id: provider.idp_entity_id.trim(),
sp_entity_id: &sp_entity_id,
acs_url: &acs,
request_id: Some(&flow.request_id),
now_unix: now / 1000,
skew_secs: SKEW_SECS,
certificates_der: &provider.certificates_der,
},
)
.map_err(|e| e.to_string())?;
if !state.first_sight(
&assertion.assertion_id,
assertion.not_on_or_after_unix.saturating_mul(1000),
now,
) {
return Err("this assertion was already used (a replay)".to_owned());
}
let attribute = provider.username_attribute.trim();
let from_attribute = (!attribute.is_empty())
.then(|| first(assertion.attributes.get(attribute)))
.flatten();
if !attribute.is_empty() && from_attribute.is_none() {
return Err(format!(
"the assertion carries no {attribute:?} attribute to name the person by"
));
}
let transient = assertion.name_id_format.as_deref() == Some(TRANSIENT);
// A transient NameID changes every login: the person is then the mapped
// attribute, or nobody (a new employee per login would be wrong).
let subject = if transient {
from_attribute.clone().ok_or_else(|| {
"the IdP sends a transient NameID; map the user-name attribute so the person can be recognised".to_owned()
})?
} else {
assertion.name_id.clone()
};
if subject.is_empty() || subject.len() > 512 {
return Err("the assertion names nobody".to_owned());
}
let user_name = from_attribute.unwrap_or_else(|| assertion.name_id.clone());
let display_name = match provider.display_name_attribute.trim() {
"" => first(assertion.attributes.get("displayName")).or_else(|| {
first(
assertion
.attributes
.get("http://schemas.microsoft.com/identity/claims/displayname"),
)
}),
a => first(assertion.attributes.get(a)),
}
.unwrap_or_default();
let email = first(assertion.attributes.get("email"))
.or_else(|| first(assertion.attributes.get("mail")))
.or_else(|| {
first(
assertion
.attributes
.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"),
)
})
.or_else(|| user_name.contains('@').then(|| user_name.clone()))
.unwrap_or_default();
Ok(Verified {
issuer: assertion.issuer,
subject,
user_name,
display_name,
email,
assertion_id: assertion.assertion_id,
not_on_or_after_unix: assertion.not_on_or_after_unix,
})
}
/// The SP metadata the admin uploads to the IdP.
pub fn sp_metadata(console_origin: &str, account: &str) -> String {
sp::sp_metadata_xml(
&crate::paths::sp_entity_id(console_origin, account),
&crate::paths::acs_url(console_origin),
)
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_auth_saml::test_support as ts;
fn provider() -> SamlProvider {
SamlProvider {
account: "acme".into(),
console_origin: "https://gunnar.example".into(),
idp_entity_id: "https://idp.example/saml".into(),
idp_sso_url: "https://idp.example/sso".into(),
certificates_der: vec![ts::idp_cert_der()],
username_attribute: String::new(),
display_name_attribute: String::new(),
}
}
#[test]
fn metadata_fills_the_settings_and_hand_set_fields_win() {
let state = SsoState::default();
let input = SamlInput {
metadata_xml: ts::idp_metadata_xml(
"https://idp.example/saml",
"https://idp.example/sso",
),
..SamlInput::default()
};
let r = resolve_input(&state, None, &input).expect("resolved");
assert_eq!(r.idp_entity_id, "https://idp.example/saml");
assert_eq!(r.idp_sso_url, "https://idp.example/sso");
assert_eq!(r.certificates_der, vec![ts::idp_cert_der()]);
let input = SamlInput {
idp_entity_id: "https://other.example".into(),
..input
};
assert_eq!(
resolve_input(&state, None, &input)
.expect("resolved")
.idp_entity_id,
"https://other.example"
);
let bare = SamlInput {
idp_entity_id: "e".into(),
idp_sso_url: "https://idp.example/sso".into(),
..SamlInput::default()
};
assert!(resolve_input(&state, None, &bare)
.unwrap_err()
.contains("certificate is required"));
let url = SamlInput {
metadata_url: "https://idp.example/md".into(),
..SamlInput::default()
};
assert!(resolve_input(&state, None, &url)
.unwrap_err()
.contains("no egress"));
}
#[test]
fn a_begun_flow_answers_one_request() {
let (url, state_token, flow) = begin(&provider(), 1_000_000).expect("begun");
assert!(
url.starts_with("https://idp.example/sso?SAMLRequest="),
"{url}"
);
assert!(url.contains("RelayState="));
assert!(!state_token.is_empty() && flow.saml && flow.request_id.starts_with('_'));
assert_eq!(
flow.redirect_uri,
"https://gunnar.example/auth/sso/saml/acs"
);
assert!(sp_metadata("https://gunnar.example", "acme")
.contains("https://gunnar.example/auth/sso/saml/acme"));
}
#[test]
fn a_provider_without_a_certificate_signs_nobody_in() {
let p = SamlProvider {
certificates_der: Vec::new(),
..provider()
};
assert!(begin(&p, 0).is_err());
}
}