use crate::binding::Dispatch;
use crate::error::Error;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use flate2::Compression;
use flate2::read::DeflateDecoder;
use flate2::write::DeflateEncoder;
use percent_encoding::percent_decode_str;
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use std::io::Read;
use std::io::Write;
use url::Url;
const ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'!')
.add(b'"')
.add(b'#')
.add(b'$')
.add(b'%')
.add(b'&')
.add(b'\'')
.add(b'(')
.add(b')')
.add(b'*')
.add(b'+')
.add(b',')
.add(b'/')
.add(b':')
.add(b';')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
const MAX_INFLATED_BYTES: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedirectDirection {
Request,
Response,
}
impl RedirectDirection {
const fn param_name(self) -> &'static str {
match self {
RedirectDirection::Request => "SAMLRequest",
RedirectDirection::Response => "SAMLResponse",
}
}
}
pub(crate) fn encode_unsigned(
destination: &Url,
direction: RedirectDirection,
xml: &[u8],
relay_state: Option<&str>,
) -> Result<Dispatch, Error> {
let encoded_payload = deflate_and_base64(xml)?;
let mut query = String::new();
write_pct_pair(&mut query, direction.param_name(), &encoded_payload);
if let Some(rs) = relay_state {
query.push('&');
write_pct_pair(&mut query, "RelayState", rs);
}
let url = append_query(destination, &query)?;
Ok(Dispatch::Redirect(url))
}
pub(crate) fn encode_signed(
destination: &Url,
direction: RedirectDirection,
xml: &[u8],
relay_state: Option<&str>,
sig_alg_uri: &str,
sign: impl FnOnce(&[u8]) -> Result<Vec<u8>, Error>,
) -> Result<Dispatch, Error> {
let encoded_payload = deflate_and_base64(xml)?;
let mut signed_qs = String::new();
write_pct_pair(&mut signed_qs, direction.param_name(), &encoded_payload);
if let Some(rs) = relay_state {
signed_qs.push('&');
write_pct_pair(&mut signed_qs, "RelayState", rs);
}
signed_qs.push('&');
write_pct_pair(&mut signed_qs, "SigAlg", sig_alg_uri);
let signature_bytes = sign(signed_qs.as_bytes())?;
let signature_b64 = BASE64.encode(&signature_bytes);
let mut full_query = signed_qs;
full_query.push('&');
write_pct_pair(&mut full_query, "Signature", &signature_b64);
let url = append_query(destination, &full_query)?;
Ok(Dispatch::Redirect(url))
}
fn write_pct_pair(out: &mut String, name: &str, value: &str) {
out.push_str(name);
out.push('=');
for chunk in utf8_percent_encode(value, ENCODE_SET) {
out.push_str(chunk);
}
}
#[derive(Debug, Clone)]
pub struct DecodedRedirect {
pub xml: Vec<u8>,
pub relay_state: Option<String>,
pub signature: Option<Vec<u8>>,
pub sig_alg: Option<String>,
pub signed_query_string: Option<String>,
}
pub fn decode(
raw_query_string: &str,
direction: RedirectDirection,
) -> Result<DecodedRedirect, Error> {
let param_name = direction.param_name();
let mut saml_raw: Option<&str> = None;
let mut relay_state_raw: Option<&str> = None;
let mut sig_alg_raw: Option<&str> = None;
let mut signature_raw: Option<&str> = None;
let mut has_signature_pair = false;
for pair in raw_query_string.split('&') {
if pair.is_empty() {
continue;
}
let (k, v) = match pair.split_once('=') {
Some(kv) => kv,
None => (pair, ""),
};
match k {
n if n == param_name => saml_raw = Some(v),
"RelayState" => relay_state_raw = Some(v),
"SigAlg" => sig_alg_raw = Some(v),
"Signature" => {
signature_raw = Some(v);
has_signature_pair = true;
}
_ => {}
}
}
let saml_pct = saml_raw.ok_or(Error::InvalidConfiguration {
reason: "missing SAMLRequest/SAMLResponse parameter",
})?;
let saml_b64 = pct_decode(saml_pct)?;
let deflated = BASE64
.decode(saml_b64.as_bytes())
.map_err(|_err| Error::Base64Decode)?;
let xml = inflate_capped(&deflated)?;
let relay_state = relay_state_raw.map(pct_decode).transpose()?;
let sig_alg = sig_alg_raw.map(pct_decode).transpose()?;
let signature = match signature_raw {
Some(sig_pct) => {
let sig_b64 = pct_decode(sig_pct)?;
let bytes = BASE64
.decode(sig_b64.as_bytes())
.map_err(|_err| Error::Base64Decode)?;
Some(bytes)
}
None => None,
};
let signed_query_string = if has_signature_pair {
let mut qs = format!("{param_name}={saml_pct}");
if let Some(rs) = relay_state_raw {
qs.push_str("&RelayState=");
qs.push_str(rs);
}
if let Some(sa) = sig_alg_raw {
qs.push_str("&SigAlg=");
qs.push_str(sa);
}
Some(qs)
} else {
None
};
Ok(DecodedRedirect {
xml,
relay_state,
signature,
sig_alg,
signed_query_string,
})
}
fn pct_decode(value: &str) -> Result<String, Error> {
percent_decode_str(value)
.decode_utf8()
.map(std::borrow::Cow::into_owned)
.map_err(|_err| Error::Base64Decode)
}
fn deflate_and_base64(xml: &[u8]) -> Result<String, Error> {
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(xml)
.map_err(|e| Error::XmlEmit(format!("DEFLATE write failed: {e}")))?;
let deflated = encoder
.finish()
.map_err(|e| Error::XmlEmit(format!("DEFLATE finish failed: {e}")))?;
Ok(BASE64.encode(&deflated))
}
fn inflate_capped(deflated: &[u8]) -> Result<Vec<u8>, Error> {
let mut decoder = DeflateDecoder::new(deflated);
let mut buf = Vec::new();
let limit = (MAX_INFLATED_BYTES as u64)
.checked_add(1)
.ok_or(Error::Inflate)?;
let mut limited = (&mut decoder).take(limit);
limited
.read_to_end(&mut buf)
.map_err(|_err| Error::Inflate)?;
if buf.len() > MAX_INFLATED_BYTES {
return Err(Error::Inflate);
}
Ok(buf)
}
fn append_query(base: &Url, query: &str) -> Result<Url, Error> {
let mut s = base.as_str().to_string();
if let Some(fragment_start) = s.find('#') {
s.truncate(fragment_start);
}
if s.contains('?') {
if !s.ends_with('?') && !s.ends_with('&') {
s.push('&');
}
} else {
s.push('?');
}
s.push_str(query);
Url::parse(&s).map_err(|_err| Error::InvalidConfiguration {
reason: "destination URL is not a valid base URL",
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::Dispatch;
fn dest() -> Url {
Url::parse("https://idp.example.com/sso").unwrap()
}
fn extract_query(d: &Dispatch) -> String {
match d {
Dispatch::Redirect(url) => url.query().expect("redirect has query").to_string(),
Dispatch::Post(_) => panic!("expected Redirect"),
}
}
#[test]
fn unsigned_roundtrip_request() {
let xml = b"<samlp:AuthnRequest/>";
let dispatch = encode_unsigned(&dest(), RedirectDirection::Request, xml, None).unwrap();
let query = extract_query(&dispatch);
let decoded = decode(&query, RedirectDirection::Request).unwrap();
assert_eq!(decoded.xml, xml);
assert!(decoded.relay_state.is_none());
assert!(decoded.signature.is_none());
assert!(decoded.sig_alg.is_none());
assert!(decoded.signed_query_string.is_none());
}
#[test]
fn unsigned_roundtrip_response() {
let xml = b"<samlp:Response/>";
let dispatch = encode_unsigned(&dest(), RedirectDirection::Response, xml, None).unwrap();
let query = extract_query(&dispatch);
let decoded = decode(&query, RedirectDirection::Response).unwrap();
assert_eq!(decoded.xml, xml);
}
#[test]
fn unsigned_roundtrip_with_relay_state() {
let xml = b"<samlp:AuthnRequest ID=\"_abc\"/>";
let relay = "https://sp.example.com/resource?id=42&foo=bar";
let dispatch =
encode_unsigned(&dest(), RedirectDirection::Request, xml, Some(relay)).unwrap();
let query = extract_query(&dispatch);
let decoded = decode(&query, RedirectDirection::Request).unwrap();
assert_eq!(decoded.xml, xml);
assert_eq!(decoded.relay_state.as_deref(), Some(relay));
}
#[test]
fn encoded_payload_has_no_raw_base64_special_chars_in_query() {
let xml = vec![0u8; 256]; let dispatch = encode_unsigned(&dest(), RedirectDirection::Request, &xml, None).unwrap();
let query = extract_query(&dispatch);
let value = query.strip_prefix("SAMLRequest=").unwrap();
assert!(!value.contains('+'), "raw `+` leaked into query: {value}");
assert!(!value.contains('/'), "raw `/` leaked into query: {value}");
assert!(!value.contains('='), "raw `=` leaked into query: {value}");
}
#[test]
fn decode_precomputed_payload() {
let xml = b"<r/>";
let encoded = deflate_and_base64(xml).unwrap();
let payload_pct = utf8_percent_encode(&encoded, ENCODE_SET).to_string();
let query = format!("SAMLRequest={payload_pct}");
let decoded = decode(&query, RedirectDirection::Request).unwrap();
assert_eq!(decoded.xml, xml);
}
#[test]
fn decode_ignores_unknown_params() {
let xml = b"<r/>";
let encoded = deflate_and_base64(xml).unwrap();
let payload_pct = utf8_percent_encode(&encoded, ENCODE_SET).to_string();
let query = format!("foo=bar&SAMLRequest={payload_pct}&baz=qux");
let decoded = decode(&query, RedirectDirection::Request).unwrap();
assert_eq!(decoded.xml, xml);
}
#[test]
fn decode_missing_saml_param() {
let err = decode("RelayState=foo", RedirectDirection::Request).unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert!(reason.contains("SAMLRequest"));
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn signed_encode_decode_roundtrip() {
let xml = b"<samlp:AuthnRequest ID=\"_x\"/>";
let sig_alg = "test-alg";
let dispatch = encode_signed(
&dest(),
RedirectDirection::Request,
xml,
None,
sig_alg,
|signed_bytes| {
assert!(signed_bytes.starts_with(b"SAMLRequest="));
assert!(signed_bytes.ends_with(b"&SigAlg=test-alg"));
Ok(vec![0u8; 64])
},
)
.unwrap();
let query = extract_query(&dispatch);
let decoded = decode(&query, RedirectDirection::Request).unwrap();
assert_eq!(decoded.xml, xml);
assert_eq!(decoded.signature, Some(vec![0u8; 64]));
assert_eq!(decoded.sig_alg.as_deref(), Some("test-alg"));
assert!(decoded.signed_query_string.is_some());
}
#[test]
fn signed_canonical_query_string_round_trip_identity() {
let xml = b"<samlp:AuthnRequest ID=\"_y\"/>";
let relay = "state-token-123";
let sig_alg = "urn:test:alg";
let captured_signed_bytes: std::cell::RefCell<Option<Vec<u8>>> =
std::cell::RefCell::new(None);
let dispatch = encode_signed(
&dest(),
RedirectDirection::Request,
xml,
Some(relay),
sig_alg,
|signed_bytes| {
*captured_signed_bytes.borrow_mut() = Some(signed_bytes.to_vec());
Ok(vec![1u8; 32])
},
)
.unwrap();
let query = extract_query(&dispatch);
let decoded = decode(&query, RedirectDirection::Request).unwrap();
let encoded_signed = captured_signed_bytes.into_inner().unwrap();
let decoded_signed = decoded.signed_query_string.unwrap();
assert_eq!(
decoded_signed.as_bytes(),
encoded_signed.as_slice(),
"canonical signed query string must round-trip byte-for-byte"
);
}
#[test]
fn signed_response_canonical_order_uses_samlresponse() {
let xml = b"<samlp:Response ID=\"_r\"/>";
let dispatch = encode_signed(
&dest(),
RedirectDirection::Response,
xml,
Some("rs"),
"alg-x",
|signed_bytes| {
let s = std::str::from_utf8(signed_bytes).unwrap();
assert!(s.starts_with("SAMLResponse="));
assert!(s.contains("&RelayState=rs"));
assert!(s.ends_with("&SigAlg=alg-x"));
Ok(vec![7u8; 16])
},
)
.unwrap();
let query = extract_query(&dispatch);
let decoded = decode(&query, RedirectDirection::Response).unwrap();
assert_eq!(decoded.xml, xml);
assert_eq!(decoded.relay_state.as_deref(), Some("rs"));
assert_eq!(decoded.sig_alg.as_deref(), Some("alg-x"));
assert_eq!(decoded.signature, Some(vec![7u8; 16]));
}
#[test]
fn signed_without_relay_state_omits_segment_entirely() {
let xml = b"<samlp:AuthnRequest/>";
let dispatch = encode_signed(
&dest(),
RedirectDirection::Request,
xml,
None,
"alg-y",
|signed_bytes| {
let s = std::str::from_utf8(signed_bytes).unwrap();
assert!(!s.contains("RelayState"), "RelayState must be omitted: {s}");
assert!(s.contains("&SigAlg=alg-y"));
Ok(vec![2u8; 8])
},
)
.unwrap();
let _ = dispatch;
}
#[test]
fn decode_unsigned_when_no_signature_param() {
let xml = b"<r/>";
let encoded = deflate_and_base64(xml).unwrap();
let payload_pct = utf8_percent_encode(&encoded, ENCODE_SET).to_string();
let query = format!("SAMLRequest={payload_pct}&SigAlg=anything");
let decoded = decode(&query, RedirectDirection::Request).unwrap();
assert!(decoded.signature.is_none());
assert!(decoded.signed_query_string.is_none());
assert_eq!(decoded.sig_alg.as_deref(), Some("anything"));
}
#[test]
fn malformed_base64_yields_base64_decode_error() {
let query = "SAMLRequest=%21%21%21%21";
let err = decode(query, RedirectDirection::Request).unwrap_err();
match err {
Error::Base64Decode => {}
other => panic!("expected Base64Decode, got {other:?}"),
}
}
#[test]
fn zip_bomb_defense_oversize_inflation_rejected() {
let huge = vec![b'A'; MAX_INFLATED_BYTES + 1];
let mut enc = DeflateEncoder::new(Vec::new(), Compression::best());
enc.write_all(&huge).unwrap();
let deflated = enc.finish().unwrap();
let b64 = BASE64.encode(&deflated);
let pct = utf8_percent_encode(&b64, ENCODE_SET).to_string();
let query = format!("SAMLRequest={pct}");
let err = decode(&query, RedirectDirection::Request).unwrap_err();
match err {
Error::Inflate => {}
other => panic!("expected Inflate, got {other:?}"),
}
}
#[test]
fn append_query_preserves_existing_query() {
let base = Url::parse("https://idp.example.com/sso?tenant=foo").unwrap();
let xml = b"<x/>";
let dispatch = encode_unsigned(&base, RedirectDirection::Request, xml, None).unwrap();
let Dispatch::Redirect(url) = dispatch else {
panic!("expected Redirect");
};
let q = url.query().unwrap();
assert!(q.starts_with("tenant=foo&SAMLRequest="), "got {q}");
}
}