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('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
}
/// 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());
}
}