Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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());
    }
}