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

nordisk/edda

crates/nornir-auth-saml/src/fake_idp.rs

//! **A fake SAML IdP, in process** (feature `test-support`): metadata, and an
//! HTTP-Redirect sign-on endpoint that reads the SP's AuthnRequest and answers
//! a browser with the auto-submitting HTTP-POST form real IdPs send — a
//! signed Response for whoever the test says, `InResponseTo` the request, to
//! the request's ACS. Over plain HTTP on `127.0.0.1:0`; signed with
//! [`crate::test_support`]'s throwaway key.
//!
//! [`Tweak`] breaks exactly one thing in the NEXT response. Never in a release
//! build: the module exists only behind `test-support`.

use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

use base64::Engine as _;

use crate::sp::{ASSERTION, PROTOCOL};
use crate::test_support::{self as ts, ResponseParams, Sign};

/// Who the fake signs in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Person {
    /// The NameID (persistent).
    pub name_id: String,
    pub email: String,
    pub display_name: String,
}

impl Person {
    pub fn new(name_id: &str, email: &str, display_name: &str) -> Person {
        Person {
            name_id: name_id.to_owned(),
            email: email.to_owned(),
            display_name: display_name.to_owned(),
        }
    }
}

/// One thing wrong with the NEXT response.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tweak {
    None,
    /// The audience is another SP.
    WrongAudience,
    /// Expired ten minutes ago.
    Expired,
    /// Signed by the attacker's key.
    ForeignKey,
    /// Not signed at all.
    Unsigned,
    /// Issued by another IdP's entity id.
    WrongIssuer,
}

struct Inner {
    person: Person,
    tweak: Tweak,
    seq: u64,
}

/// A running fake IdP. Dropped: it stops answering.
pub struct FakeIdp {
    base: String,
    entity_id: String,
    inner: Arc<Mutex<Inner>>,
    stop: Arc<AtomicBool>,
}

fn lock(m: &Mutex<Inner>) -> std::sync::MutexGuard<'_, Inner> {
    m.lock().unwrap_or_else(|p| p.into_inner())
}

fn now() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

fn decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i < b.len() {
        match b[i] {
            b'+' => out.push(b' '),
            b'%' if i + 2 < b.len() => {
                if let Some(v) = std::str::from_utf8(&b[i + 1..i + 3])
                    .ok()
                    .and_then(|h| u8::from_str_radix(h, 16).ok())
                {
                    out.push(v);
                    i += 2;
                } else {
                    out.push(b'%');
                }
            }
            c => out.push(c),
        }
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

fn query_of(url: &str) -> HashMap<String, String> {
    url.split_once('?')
        .map(|(_, q)| q)
        .unwrap_or("")
        .split('&')
        .filter_map(|p| p.split_once('='))
        .map(|(k, v)| (decode(k), decode(v)))
        .collect()
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('"', "&quot;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

/// What the IdP posts back: where, the `SAMLResponse`, the `RelayState`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Posted {
    pub acs_url: String,
    pub saml_response: String,
    pub relay_state: String,
}

impl FakeIdp {
    /// Bind `127.0.0.1:0` and serve: `/metadata` and `/sso`.
    pub fn start() -> std::io::Result<FakeIdp> {
        let listener = TcpListener::bind("127.0.0.1:0")?;
        let base = format!("http://{}", listener.local_addr()?);
        let entity_id = format!("{base}/metadata");
        let inner = Arc::new(Mutex::new(Inner {
            person: Person::new("carol@acme.example", "carol@acme.example", "Carol Example"),
            tweak: Tweak::None,
            seq: 0,
        }));
        let stop = Arc::new(AtomicBool::new(false));
        let idp = FakeIdp {
            base: base.clone(),
            entity_id: entity_id.clone(),
            inner: Arc::clone(&inner),
            stop: Arc::clone(&stop),
        };
        // A32-14, stays a thread: the fake IdP's accept loop, a test double (feature test-support), never in a box.
        std::thread::spawn(move || {
            for stream in listener.incoming() {
                if stop.load(Ordering::SeqCst) {
                    break;
                }
                let Ok(stream) = stream else { continue };
                let _ = serve(stream, &base, &entity_id, &inner);
            }
        });
        Ok(idp)
    }

    /// The IdP's entity id (its metadata URL).
    pub fn entity_id(&self) -> &str {
        &self.entity_id
    }

    /// The HTTP-Redirect sign-on URL.
    pub fn sso_url(&self) -> String {
        format!("{}/sso", self.base)
    }

    pub fn metadata_url(&self) -> String {
        self.entity_id.clone()
    }

    pub fn metadata_xml(&self) -> String {
        ts::idp_metadata_xml(&self.entity_id, &self.sso_url())
    }

    pub fn set_person(&self, person: Person) {
        lock(&self.inner).person = person;
    }

    pub fn tweak_next(&self, tweak: Tweak) {
        lock(&self.inner).tweak = tweak;
    }

    /// **The sign-on, without a browser**: what the IdP would post back for
    /// the SP's redirect URL.
    pub fn respond(&self, redirect_url: &str) -> Result<Posted, String> {
        respond(&self.entity_id, &self.inner, redirect_url)
    }
}

impl Drop for FakeIdp {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::SeqCst);
        let _ = TcpStream::connect(self.base.trim_start_matches("http://"));
    }
}

fn respond(entity_id: &str, inner: &Mutex<Inner>, redirect_url: &str) -> Result<Posted, String> {
    let q = query_of(redirect_url);
    let request = q.get("SAMLRequest").ok_or("no SAMLRequest")?;
    let relay_state = q.get("RelayState").cloned().unwrap_or_default();
    let deflated = base64::engine::general_purpose::STANDARD
        .decode(request.as_bytes())
        .map_err(|e| format!("SAMLRequest: {e}"))?;
    let mut xml = String::new();
    flate2::read::DeflateDecoder::new(&deflated[..])
        .read_to_string(&mut xml)
        .map_err(|e| format!("inflate: {e}"))?;
    let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("AuthnRequest: {e}"))?;
    let root = doc.root_element();
    if root.tag_name().namespace() != Some(PROTOCOL) || root.tag_name().name() != "AuthnRequest" {
        return Err("not an AuthnRequest".into());
    }
    let id = root.attribute("ID").ok_or("no ID")?.to_owned();
    let acs = root
        .attribute("AssertionConsumerServiceURL")
        .ok_or("no ACS URL")?
        .to_owned();
    let sp_entity = root
        .descendants()
        .find(|n| {
            n.is_element()
                && n.tag_name().namespace() == Some(ASSERTION)
                && n.tag_name().name() == "Issuer"
        })
        .and_then(|n| n.text())
        .ok_or("no Issuer")?
        .trim()
        .to_owned();
    let (person, tweak, seq) = {
        let mut i = lock(inner);
        i.seq += 1;
        let tweak = std::mem::replace(&mut i.tweak, Tweak::None);
        (i.person.clone(), tweak, i.seq)
    };
    let t = now();
    let mut p = ResponseParams::new(entity_id, &person.name_id, &sp_entity, &acs, t);
    p.name_id_format = Some("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent".to_owned());
    p.response_id = format!("_r{t:x}{seq:04x}");
    p.assertion_id = format!("_a{t:x}{seq:04x}");
    p.in_response_to = Some(id);
    p.attributes = vec![
        ("email".to_owned(), vec![person.email.clone()]),
        ("displayName".to_owned(), vec![person.display_name.clone()]),
    ];
    match tweak {
        Tweak::WrongAudience => p.audience = "https://another-sp.example/sso".to_owned(),
        Tweak::Expired => {
            p.not_before = t - 1200;
            p.not_on_or_after = t - 600;
        }
        Tweak::WrongIssuer => p.issuer = "https://another-idp.example/metadata".to_owned(),
        _ => {}
    }
    let unsigned = ts::response_xml(&p);
    let signed = match tweak {
        Tweak::Unsigned => unsigned,
        Tweak::ForeignKey => {
            let (key, cert) = ts::attacker_key();
            ts::sign_response_with_key(&unsigned, Sign::Assertion, &key, &cert)
        }
        _ => ts::sign_response(&unsigned, Sign::Assertion),
    };
    Ok(Posted {
        acs_url: acs,
        saml_response: ts::encode(&signed),
        relay_state,
    })
}

fn write(
    stream: &mut TcpStream,
    status: &str,
    content_type: &str,
    body: &str,
) -> std::io::Result<()> {
    write!(
        stream,
        "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    )?;
    stream.flush()
}

fn serve(
    mut stream: TcpStream,
    base: &str,
    entity_id: &str,
    inner: &Mutex<Inner>,
) -> std::io::Result<()> {
    stream.set_read_timeout(Some(std::time::Duration::from_secs(10)))?;
    let mut reader = BufReader::new(stream.try_clone()?);
    let mut line = String::new();
    reader.read_line(&mut line)?;
    let target = line
        .split_whitespace()
        .nth(1)
        .unwrap_or_default()
        .to_owned();
    loop {
        let mut h = String::new();
        if reader.read_line(&mut h)? == 0 || h == "\r\n" || h == "\n" {
            break;
        }
    }
    let path = target.split('?').next().unwrap_or_default();
    match path {
        "/metadata" => write(
            &mut stream,
            "200 OK",
            "application/samlmetadata+xml",
            &ts::idp_metadata_xml(entity_id, &format!("{base}/sso")),
        ),
        "/sso" => match respond(entity_id, inner, &target) {
            Ok(posted) => {
                let page = format!(
                    "<!doctype html><html><body onload=\"document.forms[0].submit()\">\
                     <form method=\"post\" action=\"{}\">\
                     <input type=\"hidden\" name=\"SAMLResponse\" value=\"{}\">\
                     <input type=\"hidden\" name=\"RelayState\" value=\"{}\">\
                     <noscript><button type=\"submit\">Continue</button></noscript>\
                     </form></body></html>",
                    html_escape(&posted.acs_url),
                    html_escape(&posted.saml_response),
                    html_escape(&posted.relay_state)
                );
                write(&mut stream, "200 OK", "text/html; charset=utf-8", &page)
            }
            Err(why) => write(&mut stream, "400 Bad Request", "text/plain", &why),
        },
        _ => write(
            &mut stream,
            "404 Not Found",
            "text/plain",
            "no such endpoint",
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sp;

    #[test]
    fn the_fake_answers_an_authn_request_with_a_response_the_sp_accepts() {
        let idp = FakeIdp::start().unwrap();
        let acs = "http://127.0.0.1:9/auth/sso/saml/acs";
        let sp_entity = "http://127.0.0.1:9/auth/sso/saml/acme";
        let xml = sp::authn_request_xml(
            "_req1",
            &sp::format_instant(now()),
            sp_entity,
            acs,
            &idp.sso_url(),
        );
        let url = sp::redirect_url(&idp.sso_url(), &xml, "state-1").unwrap();
        let posted = idp.respond(&url).unwrap();
        assert_eq!(posted.acs_url, acs);
        assert_eq!(posted.relay_state, "state-1");
        let md = sp::parse_idp_metadata(&idp.metadata_xml()).unwrap();
        let a = sp::verify_response_b64(
            &posted.saml_response,
            &sp::Expect {
                idp_entity_id: idp.entity_id(),
                sp_entity_id: sp_entity,
                acs_url: acs,
                request_id: Some("_req1"),
                now_unix: now(),
                skew_secs: 60,
                certificates_der: &md.certificates_der,
            },
        )
        .unwrap();
        assert_eq!(a.name_id, "carol@acme.example");
        idp.tweak_next(Tweak::ForeignKey);
        let forged = idp.respond(&url).unwrap();
        assert!(sp::verify_response_b64(
            &forged.saml_response,
            &sp::Expect {
                idp_entity_id: idp.entity_id(),
                sp_entity_id: sp_entity,
                acs_url: acs,
                request_id: Some("_req1"),
                now_unix: now(),
                skew_secs: 60,
                certificates_der: &md.certificates_der,
            },
        )
        .is_err());
    }
}