mod assertion;
mod der;
mod metadata;
mod xml;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use crate::application::Application;
use crate::body::form_urlencoded::FormUrlEncoded;
use crate::core::New;
use crate::extract::{FromRequest, Query};
use crate::header::Header;
use crate::middleware::Middleware;
use crate::mime_type::MimeType;
use crate::range::Range;
use crate::request::{Request, METHOD};
use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
use crate::server::ConnectionInfo;
use crate::session::{destroy_cookie, session_cookie, session_id_from_request, SessionStore};
pub use metadata::SamlIdpMetadata;
use metadata::decode_base64_flexible;
const SESSION_COOKIE: &str = "_rws_saml_sid";
const SESSION_TTL: u64 = 86_400;
pub const CLAIMS_HEADER: &str = "X-Rws-Saml-Claims";
const METADATA_PATH: &str = "/saml/metadata";
const LOGIN_PATH: &str = "/saml/login";
const ACS_PATH: &str = "/saml/acs";
const LOGOUT_PATH: &str = "/saml/logout";
#[derive(Clone, Default)]
pub struct AttributeMap {
mappings: Vec<(String, String)>,
}
impl AttributeMap {
pub fn new() -> Self {
AttributeMap { mappings: Vec::new() }
}
pub fn map(mut self, saml_attribute_name: &str, field: &str) -> Self {
self.mappings.push((saml_attribute_name.to_string(), field.to_string()));
self
}
fn apply(&self, raw: &HashMap<String, String>) -> HashMap<String, String> {
let mut out = HashMap::new();
for (saml_name, field) in &self.mappings {
if let Some(v) = raw.get(saml_name) {
out.insert(field.clone(), v.clone());
}
}
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlClaims {
pub name_id: String,
pub attributes: HashMap<String, String>,
}
pub struct SamlConfig {
pub sp_entity_id: String,
pub sp_acs_url: String,
pub idp_metadata: SamlIdpMetadata,
pub sessions: Arc<SessionStore>,
}
pub struct SamlSp {
config: Arc<SamlConfig>,
attribute_map: AttributeMap,
post_login_redirect: String,
login_path: String,
acs_path: String,
logout_path: String,
metadata_path: String,
}
impl SamlSp {
pub fn new(config: SamlConfig) -> Self {
SamlSp {
config: Arc::new(config),
attribute_map: AttributeMap::new(),
post_login_redirect: "/".to_string(),
login_path: LOGIN_PATH.to_string(),
acs_path: ACS_PATH.to_string(),
logout_path: LOGOUT_PATH.to_string(),
metadata_path: METADATA_PATH.to_string(),
}
}
pub fn attribute_map(mut self, map: AttributeMap) -> Self {
self.attribute_map = map;
self
}
pub fn post_login_redirect(mut self, path: &str) -> Self {
self.post_login_redirect = path.to_string();
self
}
pub fn login_path(mut self, path: &str) -> Self {
self.login_path = path.to_string();
self
}
pub fn acs_path(mut self, path: &str) -> Self {
self.acs_path = path.to_string();
self
}
pub fn logout_path(mut self, path: &str) -> Self {
self.logout_path = path.to_string();
self
}
pub fn metadata_path(mut self, path: &str) -> Self {
self.metadata_path = path.to_string();
self
}
pub fn claims(req: &Request) -> Option<SamlClaims> {
req.headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case(CLAIMS_HEADER))
.and_then(|h| serde_json::from_str(&h.value).ok())
}
pub fn name_id(req: &Request) -> Option<String> {
Self::claims(req).map(|c| c.name_id)
}
pub fn attr(req: &Request, field: &str) -> Option<String> {
Self::claims(req).and_then(|c| c.attributes.get(field).cloned())
}
fn handle_metadata(&self) -> Response {
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{entity}"><SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnabled="urn:oasis:names:tc:SAML:2.0:protocol"><AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{acs}" index="0" isDefault="true"/></SPSSODescriptor></EntityDescriptor>"#,
entity = xml_escape(&self.config.sp_entity_id),
acs = xml_escape(&self.config.sp_acs_url),
);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![Range::get_content_range(xml.into_bytes(), "application/samlmetadata+xml".to_string())];
r
}
fn handle_login(&self, request: &Request) -> Response {
let query = Query::from_request(request).map(|q| q.0).unwrap_or_default();
let return_to = query.get("return_to").cloned().unwrap_or_else(|| self.post_login_redirect.clone());
let request_id = format!("_{}", random_hex(16));
let authn_request_xml = format!(
r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{id}" Version="2.0" IssueInstant="{issued}" Destination="{dest}" AssertionConsumerServiceURL="{acs}" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"><saml:Issuer>{issuer}</saml:Issuer></samlp:AuthnRequest>"#,
id = request_id,
issued = iso8601_now(),
dest = xml_escape(&self.config.idp_metadata.sso_url),
acs = xml_escape(&self.config.sp_acs_url),
issuer = xml_escape(&self.config.sp_entity_id),
);
let saml_request_b64 = base64_standard_encode(authn_request_xml.as_bytes());
let mut session = self.config.sessions.create();
session.set("_saml_request_id", &request_id);
session.set("_saml_return_to", &return_to);
self.config.sessions.save(&session);
let html = format!(
r#"<!doctype html><html><body onload="document.forms[0].submit()"><noscript><p>Click the button to continue.</p></noscript><form method="POST" action="{action}"><input type="hidden" name="SAMLRequest" value="{req}"/><noscript><button type="submit">Continue</button></noscript></form></body></html>"#,
action = xml_escape(&self.config.idp_metadata.sso_url),
req = xml_escape(&saml_request_b64),
);
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
r.content_range_list = vec![Range::get_content_range(html.into_bytes(), MimeType::TEXT_HTML.to_string())];
r.headers.push(Header { name: "Set-Cookie".to_string(), value: session_cookie(&session.id, SESSION_COOKIE, SESSION_TTL) });
r
}
fn handle_acs(&self, request: &Request) -> Response {
let sid = match session_id_from_request(request, SESSION_COOKIE) {
Some(id) => id,
None => return Self::error_response("missing SP session cookie"),
};
let mut session = match self.config.sessions.load(&sid) {
Some(s) => s,
None => return Self::error_response("unknown or expired SP session"),
};
let request_id = session.get("_saml_request_id").map(|s| s.to_string());
let return_to = session.get("_saml_return_to").unwrap_or(&self.post_login_redirect).to_string();
let form = match FormUrlEncoded::parse(request.body.clone()) {
Ok(f) => f,
Err(e) => return Self::error_response(&format!("malformed ACS request body: {e}")),
};
let saml_response_b64 = match form.get("SAMLResponse") {
Some(v) => v,
None => return Self::error_response("missing SAMLResponse field"),
};
let raw_xml_bytes = match decode_base64_flexible(saml_response_b64) {
Ok(b) => b,
Err(e) => return Self::error_response(&e.0),
};
let raw_xml = match String::from_utf8(raw_xml_bytes) {
Ok(s) => s,
Err(_) => return Self::error_response("SAMLResponse is not valid UTF-8"),
};
let verified = assertion::parse_and_verify(
&raw_xml,
&self.config.idp_metadata.entity_id,
&self.config.idp_metadata.signing_key,
&self.config.sp_entity_id,
&self.config.sp_acs_url,
request_id.as_deref(),
unix_now(),
);
let verified = match verified {
Ok(v) => v,
Err(e) => return Self::error_response(&e.0),
};
let claims = SamlClaims {
name_id: verified.name_id,
attributes: self.attribute_map.apply(&verified.attributes),
};
session.remove("_saml_request_id");
session.remove("_saml_return_to");
let claims_json = serde_json::to_string(&claims).unwrap_or_default();
session.set("_saml_claims", &claims_json);
self.config.sessions.save(&session);
let mut r = Self::redirect(&return_to);
r.headers.push(Header { name: "Set-Cookie".to_string(), value: session_cookie(&session.id, SESSION_COOKIE, SESSION_TTL) });
r
}
fn handle_logout(&self, request: &Request) -> Response {
if let Some(sid) = session_id_from_request(request, SESSION_COOKIE) {
self.config.sessions.destroy(&sid);
}
let mut r = Self::redirect("/");
r.headers.push(Header { name: "Set-Cookie".to_string(), value: destroy_cookie(SESSION_COOKIE) });
r
}
fn redirect(url: &str) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n302_found.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase.to_string();
r.headers.push(Header { name: "Location".to_string(), value: url.to_string() });
r
}
fn error_response(msg: &str) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n500_internal_server_error.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n500_internal_server_error.reason_phrase.to_string();
r.content_range_list = vec![Range::get_content_range(msg.as_bytes().to_vec(), MimeType::TEXT_PLAIN.to_string())];
r
}
}
impl Middleware for SamlSp {
fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
let path = request.request_uri.split('?').next().unwrap_or("");
let is_get = request.method.eq_ignore_ascii_case(METHOD.get);
let is_post = request.method.eq_ignore_ascii_case(METHOD.post);
if path == self.metadata_path && is_get {
return Ok(self.handle_metadata());
}
if path == self.login_path && is_get {
return Ok(self.handle_login(request));
}
if path == self.acs_path && is_post {
return Ok(self.handle_acs(request));
}
if path == self.logout_path && is_get {
return Ok(self.handle_logout(request));
}
if let Some(sid) = session_id_from_request(request, SESSION_COOKIE) {
if let Some(session) = self.config.sessions.load(&sid) {
if let Some(claims_json) = session.get("_saml_claims") {
let mut req = request.clone();
req.headers.push(Header { name: CLAIMS_HEADER.to_string(), value: claims_json.to_string() });
return next.execute(&req, connection);
}
}
}
let return_to = crate::sso::client::url_encode(&request.request_uri);
Ok(Self::redirect(&format!("{}?return_to={}", self.login_path, return_to)))
}
}
fn unix_now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn iso8601_now() -> String {
format_iso8601(unix_now())
}
fn format_iso8601(total_secs: u64) -> String {
let days = total_secs / 86_400;
let secs_of_day = total_secs % 86_400;
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
let (y, m, d) = days_to_ymd(days);
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn days_to_ymd(days_since_epoch: u64) -> (u32, u32, u32) {
let z = days_since_epoch as i64 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y as u32, m, d)
}
fn random_hex(n_bytes: usize) -> String {
let mut bytes = vec![0u8; n_bytes];
OsRng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
const STD_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_standard_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(((bytes.len() + 2) / 3) * 4);
let mut i = 0;
while i + 3 <= bytes.len() {
let (b0, b1, b2) = (bytes[i] as usize, bytes[i + 1] as usize, bytes[i + 2] as usize);
out.push(STD_TABLE[b0 >> 2] as char);
out.push(STD_TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
out.push(STD_TABLE[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
out.push(STD_TABLE[b2 & 0x3f] as char);
i += 3;
}
let rem = bytes.len() - i;
if rem == 1 {
let b0 = bytes[i] as usize;
out.push(STD_TABLE[b0 >> 2] as char);
out.push(STD_TABLE[(b0 & 3) << 4] as char);
out.push_str("==");
} else if rem == 2 {
let (b0, b1) = (bytes[i] as usize, bytes[i + 1] as usize);
out.push(STD_TABLE[b0 >> 2] as char);
out.push(STD_TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char);
out.push(STD_TABLE[(b1 & 0xf) << 2] as char);
out.push('=');
}
out
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}