gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **SAML 2.0**: an AuthnRequest out (HTTP-Redirect), the signed Response
//! back (HTTP-POST to the console's ACS, relayed here), verified by
//! `nornir_auth_saml::sp` — a minimal pure-Rust verifier, because samael
//! needs xmlsec1/libxml2/OpenSSL and this appliance links no OpenSSL. Only
//! SP-initiated sign-ins: a response must answer an AuthnRequest this
//! listener sent (`InResponseTo`), and each assertion is consumed once.

use std::sync::Arc;

use base64::Engine as _;
use gunnar_proto::{SamlSettings, SsoProvider};
use nornir_auth_saml::sp;

use super::oidc::gunnar_core_paths;
use super::{Egress, Flow, SsoState, FLOW_TTL_MS, MAX_IDP_BODY, SKEW_SECS};

/// 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";

/// The settings of a SAML provider, or why it has none.
pub fn settings(provider: &SsoProvider) -> Result<&SamlSettings, String> {
    provider
        .saml
        .as_ref()
        .filter(|s| {
            !s.idp_entity_id.trim().is_empty()
                && !s.idp_sso_url.trim().is_empty()
                && !s.idp_certificates.is_empty()
        })
        .ok_or_else(|| {
            format!(
                "{}'s SAML provider has no IdP entity id, sign-on URL or certificate",
                provider.account
            )
        })
}

/// The stored certificates (base64 DER) as DER.
pub fn certificates(settings: &SamlSettings) -> Result<Vec<Vec<u8>>, String> {
    settings
        .idp_certificates
        .iter()
        .map(|c| {
            base64::engine::general_purpose::STANDARD
                .decode(c.trim())
                .map_err(|e| format!("a stored IdP certificate does not decode: {e}"))
        })
        .collect()
}

/// `SHA256:<base64>` of a certificate's DER, as a listing shows it.
pub fn fingerprint(der: &[u8]) -> String {
    use sha2::Digest as _;
    format!(
        "SHA256:{}",
        base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(der))
    )
}

/// **Fill an admin's SAML input**: metadata (pasted, or fetched from its https
/// URL) gives the entity id, the sign-on URL and the certificates; fields set
/// by hand win; every certificate is parsed (PEM or base64) and stored as
/// base64 DER. Returns the settings to store.
pub async fn resolve_input(
    state: &SsoState,
    egress: Option<Arc<dyn Egress>>,
    input: &SamlSettings,
) -> Result<SamlSettings, String> {
    let mut out = SamlSettings {
        idp_metadata_xml: String::new(),
        idp_metadata_url: input.idp_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(),
        idp_certificates: 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.idp_metadata_xml.trim().to_owned();
    if metadata_xml.is_empty() && !out.idp_metadata_url.is_empty() {
        let url = out.idp_metadata_url.clone();
        super::url_allowed_in(&url, state.allow_loopback_http(), &state.private_nets())?;
        let egress =
            egress.ok_or("this server reaches no identity provider (no egress is wired)")?;
        let answer = tokio::task::spawn_blocking(move || egress.get(&url))
            .await
            .map_err(|e| format!("the metadata fetch did not finish: {e}"))?;
        let (status, body) = answer.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.idp_certificates.is_empty() {
        certs = input
            .idp_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.idp_certificates = certs
        .iter()
        .map(|der| base64::engine::general_purpose::STANDARD.encode(der))
        .collect();
    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: &SsoProvider, now: i64) -> Result<(String, String, Flow), String> {
    let settings = settings(provider)?;
    let state_token = nornir_auth_oidc::rp::random_token(&crate::webauthn::random_bytes(32));
    let request_id = format!(
        "_{}",
        crate::webauthn::hex(&crate::webauthn::random_bytes(20))
    );
    let sp_entity_id = gunnar_core_paths::sp_entity_id(&provider.console_origin, &provider.account);
    let acs = gunnar_core_paths::acs_url(&provider.console_origin);
    let xml = sp::authn_request_xml(
        &request_id,
        &sp::format_instant(now / 1000),
        &sp_entity_id,
        &acs,
        settings.idp_sso_url.trim(),
    );
    let url = sp::redirect_url(settings.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,
    };
    Ok((url, state_token, flow))
}

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: &SsoProvider,
    flow: &Flow,
    saml_response: &str,
    now: i64,
) -> Result<Verified, String> {
    let settings = settings(provider)?;
    let certs = certificates(settings)?;
    let sp_entity_id = gunnar_core_paths::sp_entity_id(&provider.console_origin, &provider.account);
    let acs = gunnar_core_paths::acs_url(&provider.console_origin);
    let assertion = sp::verify_response_b64(
        saml_response,
        &sp::Expect {
            idp_entity_id: settings.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: &certs,
        },
    )
    .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 = settings.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 settings.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(provider: &SsoProvider) -> String {
    sp::sp_metadata_xml(
        &gunnar_core_paths::sp_entity_id(&provider.console_origin, &provider.account),
        &gunnar_core_paths::acs_url(&provider.console_origin),
    )
}