pub mod artifact;
pub mod post;
pub mod redirect;
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Binding {
HttpRedirect,
HttpPost,
HttpArtifact,
Soap,
}
impl Binding {
pub const fn uri(self) -> &'static str {
match self {
Binding::HttpRedirect => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
Binding::HttpPost => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
Binding::HttpArtifact => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact",
Binding::Soap => "urn:oasis:names:tc:SAML:2.0:bindings:SOAP",
}
}
pub fn from_uri(uri: &str) -> Result<Self, Error> {
match uri {
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" => Ok(Binding::HttpRedirect),
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" => Ok(Binding::HttpPost),
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" => Ok(Binding::HttpArtifact),
"urn:oasis:names:tc:SAML:2.0:bindings:SOAP" => Ok(Binding::Soap),
_ => Err(Error::InvalidConfiguration {
reason: "unknown binding URI",
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum SsoResponseBinding {
HttpPost,
HttpArtifact,
}
impl SsoResponseBinding {
pub const fn as_binding(self) -> Binding {
match self {
SsoResponseBinding::HttpPost => Binding::HttpPost,
SsoResponseBinding::HttpArtifact => Binding::HttpArtifact,
}
}
pub const fn from_binding(b: Binding) -> Option<Self> {
match b {
Binding::HttpPost => Some(SsoResponseBinding::HttpPost),
Binding::HttpArtifact => Some(SsoResponseBinding::HttpArtifact),
Binding::HttpRedirect | Binding::Soap => None,
}
}
pub const fn uri(self) -> &'static str {
self.as_binding().uri()
}
pub fn from_uri(uri: &str) -> Result<Self, Error> {
let binding = Binding::from_uri(uri)?;
Self::from_binding(binding).ok_or(Error::IllegalResponseBinding { requested: binding })
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Endpoint {
pub url: String,
pub binding: Binding,
pub index: Option<u16>,
pub is_default: bool,
}
impl Endpoint {
pub fn redirect(url: impl Into<String>, index: u16, is_default: bool) -> Self {
Self {
url: url.into(),
binding: Binding::HttpRedirect,
index: Some(index),
is_default,
}
}
pub fn post(url: impl Into<String>, index: u16, is_default: bool) -> Self {
Self {
url: url.into(),
binding: Binding::HttpPost,
index: Some(index),
is_default,
}
}
pub fn artifact(url: impl Into<String>, index: u16, is_default: bool) -> Self {
Self {
url: url.into(),
binding: Binding::HttpArtifact,
index: Some(index),
is_default,
}
}
pub fn soap(url: impl Into<String>, index: Option<u16>, is_default: bool) -> Self {
Self {
url: url.into(),
binding: Binding::Soap,
index,
is_default,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SsoResponseEndpoint {
pub url: String,
pub binding: SsoResponseBinding,
pub index: Option<u16>,
pub is_default: bool,
}
impl SsoResponseEndpoint {
pub fn post(url: impl Into<String>, index: u16, is_default: bool) -> Self {
Self {
url: url.into(),
binding: SsoResponseBinding::HttpPost,
index: Some(index),
is_default,
}
}
pub fn artifact(url: impl Into<String>, index: u16, is_default: bool) -> Self {
Self {
url: url.into(),
binding: SsoResponseBinding::HttpArtifact,
index: Some(index),
is_default,
}
}
pub fn as_endpoint(&self) -> Endpoint {
Endpoint {
url: self.url.clone(),
binding: self.binding.as_binding(),
index: self.index,
is_default: self.is_default,
}
}
pub fn try_from_endpoint(e: Endpoint) -> Result<Self, Error> {
let binding = SsoResponseBinding::from_binding(e.binding).ok_or(
Error::InvalidConfiguration {
reason: "ACS endpoint binding must be POST or Artifact",
},
)?;
Ok(Self {
url: e.url,
binding,
index: e.index,
is_default: e.is_default,
})
}
}
#[derive(Debug, Clone)]
pub enum Dispatch {
Redirect(url::Url),
Post(PostForm),
}
#[derive(Debug, Clone)]
pub struct PostForm {
pub action: url::Url,
pub saml_request: Option<String>,
pub saml_response: Option<String>,
pub relay_state: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SsoResponseDispatch {
Post(SsoResponsePostForm),
Artifact(ArtifactRedirect),
}
#[derive(Debug, Clone)]
pub struct SsoResponsePostForm {
pub action: url::Url,
pub saml_response: String,
pub relay_state: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ArtifactRedirect {
pub redirect_to: url::Url,
pub artifact: String,
pub response_xml: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WireDirection {
Request,
Response,
}
#[derive(Debug, Clone)]
pub struct DecodedWire {
pub xml: Vec<u8>,
pub relay_state: Option<String>,
pub detached_signature: Option<Vec<u8>>,
pub detached_sig_alg: Option<String>,
pub signed_query_string: Option<String>,
}
impl DecodedWire {
#[must_use]
pub fn as_detached_signature(&self) -> Option<crate::idp::DetachedSignature<'_>> {
Some(crate::idp::DetachedSignature {
signature: self.detached_signature.as_deref()?,
sig_alg: self.detached_sig_alg.as_deref()?,
raw_query_string: self.signed_query_string.as_deref()?,
})
}
}
pub fn decode_wire(
body: &[u8],
binding: Binding,
direction: WireDirection,
) -> Result<DecodedWire, Error> {
match binding {
Binding::HttpRedirect => {
let qs = std::str::from_utf8(body).map_err(|_err| Error::Base64Decode)?;
let redirect_direction = match direction {
WireDirection::Request => redirect::RedirectDirection::Request,
WireDirection::Response => redirect::RedirectDirection::Response,
};
let decoded = redirect::decode(qs, redirect_direction)?;
Ok(DecodedWire {
xml: decoded.xml,
relay_state: decoded.relay_state,
detached_signature: decoded.signature,
detached_sig_alg: decoded.sig_alg,
signed_query_string: decoded.signed_query_string,
})
}
Binding::HttpPost => {
let b64 = std::str::from_utf8(body).map_err(|_err| Error::Base64Decode)?;
let decoded = post::decode(b64, None)?;
Ok(DecodedWire {
xml: decoded.xml,
relay_state: decoded.relay_state,
detached_signature: None,
detached_sig_alg: None,
signed_query_string: None,
})
}
Binding::Soap | Binding::HttpArtifact => Err(Error::UnsupportedByPeer { binding }),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binding_uri_roundtrip() {
for b in [
Binding::HttpRedirect,
Binding::HttpPost,
Binding::HttpArtifact,
Binding::Soap,
] {
let uri = b.uri();
assert_eq!(Binding::from_uri(uri).unwrap(), b, "roundtrip for {b:?}");
}
}
#[test]
fn binding_uri_constants_match_spec() {
assert_eq!(
Binding::HttpRedirect.uri(),
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
);
assert_eq!(
Binding::HttpPost.uri(),
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
);
assert_eq!(
Binding::HttpArtifact.uri(),
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"
);
assert_eq!(
Binding::Soap.uri(),
"urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
);
}
#[test]
fn binding_from_unknown_uri_is_invalid_configuration() {
let err = Binding::from_uri("urn:something:else").unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert_eq!(reason, "unknown binding URI");
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn sso_response_binding_widen_narrow() {
assert_eq!(
SsoResponseBinding::HttpPost.as_binding(),
Binding::HttpPost
);
assert_eq!(
SsoResponseBinding::HttpArtifact.as_binding(),
Binding::HttpArtifact
);
assert_eq!(
SsoResponseBinding::from_binding(Binding::HttpPost),
Some(SsoResponseBinding::HttpPost)
);
assert_eq!(
SsoResponseBinding::from_binding(Binding::HttpArtifact),
Some(SsoResponseBinding::HttpArtifact)
);
assert_eq!(SsoResponseBinding::from_binding(Binding::HttpRedirect), None);
assert_eq!(SsoResponseBinding::from_binding(Binding::Soap), None);
}
#[test]
fn sso_response_binding_from_uri_accepts_post_and_artifact() {
assert_eq!(
SsoResponseBinding::from_uri(Binding::HttpPost.uri()).unwrap(),
SsoResponseBinding::HttpPost
);
assert_eq!(
SsoResponseBinding::from_uri(Binding::HttpArtifact.uri()).unwrap(),
SsoResponseBinding::HttpArtifact
);
}
#[test]
fn sso_response_binding_from_uri_rejects_redirect_and_soap() {
let err = SsoResponseBinding::from_uri(Binding::HttpRedirect.uri()).unwrap_err();
match err {
Error::IllegalResponseBinding { requested } => {
assert_eq!(requested, Binding::HttpRedirect);
}
other => panic!("expected IllegalResponseBinding, got {other:?}"),
}
let err = SsoResponseBinding::from_uri(Binding::Soap.uri()).unwrap_err();
match err {
Error::IllegalResponseBinding { requested } => {
assert_eq!(requested, Binding::Soap);
}
other => panic!("expected IllegalResponseBinding, got {other:?}"),
}
}
#[test]
fn sso_response_binding_from_uri_rejects_unknown() {
let err = SsoResponseBinding::from_uri("urn:bogus").unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert_eq!(reason, "unknown binding URI");
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn endpoint_constructors_set_expected_fields() {
let r = Endpoint::redirect("https://example.com/r", 1, false);
assert_eq!(r.url, "https://example.com/r");
assert_eq!(r.binding, Binding::HttpRedirect);
assert_eq!(r.index, Some(1));
assert!(!r.is_default);
let p = Endpoint::post("https://example.com/p", 2, true);
assert_eq!(p.binding, Binding::HttpPost);
assert_eq!(p.index, Some(2));
assert!(p.is_default);
let a = Endpoint::artifact("https://example.com/a", 3, false);
assert_eq!(a.binding, Binding::HttpArtifact);
assert_eq!(a.index, Some(3));
let s_indexed = Endpoint::soap("https://example.com/s", Some(7), true);
assert_eq!(s_indexed.binding, Binding::Soap);
assert_eq!(s_indexed.index, Some(7));
assert!(s_indexed.is_default);
let s_none = Endpoint::soap("https://example.com/s", None, false);
assert_eq!(s_none.binding, Binding::Soap);
assert_eq!(s_none.index, None);
}
#[test]
fn sso_response_endpoint_constructors_and_widening() {
let p = SsoResponseEndpoint::post("https://example.com/acs", 0, true);
assert_eq!(p.binding, SsoResponseBinding::HttpPost);
assert_eq!(p.index, Some(0));
assert!(p.is_default);
let widened = p.as_endpoint();
assert_eq!(widened.url, "https://example.com/acs");
assert_eq!(widened.binding, Binding::HttpPost);
assert_eq!(widened.index, Some(0));
assert!(widened.is_default);
let a = SsoResponseEndpoint::artifact("https://example.com/acs-art", 1, false);
assert_eq!(a.binding, SsoResponseBinding::HttpArtifact);
assert_eq!(a.as_endpoint().binding, Binding::HttpArtifact);
}
#[test]
fn sso_response_endpoint_try_from_post_and_artifact_succeeds() {
let post = Endpoint::post("https://example.com/acs", 0, true);
let narrowed = SsoResponseEndpoint::try_from_endpoint(post).unwrap();
assert_eq!(narrowed.binding, SsoResponseBinding::HttpPost);
assert_eq!(narrowed.url, "https://example.com/acs");
assert_eq!(narrowed.index, Some(0));
assert!(narrowed.is_default);
let art = Endpoint::artifact("https://example.com/acs-art", 2, false);
let narrowed = SsoResponseEndpoint::try_from_endpoint(art).unwrap();
assert_eq!(narrowed.binding, SsoResponseBinding::HttpArtifact);
assert_eq!(narrowed.index, Some(2));
assert!(!narrowed.is_default);
}
#[test]
fn sso_response_endpoint_try_from_redirect_rejected() {
let redirect = Endpoint::redirect("https://example.com/r", 0, true);
let err = SsoResponseEndpoint::try_from_endpoint(redirect).unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert_eq!(reason, "ACS endpoint binding must be POST or Artifact");
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn sso_response_endpoint_try_from_soap_rejected() {
let soap = Endpoint::soap("https://example.com/s", Some(0), true);
let err = SsoResponseEndpoint::try_from_endpoint(soap).unwrap_err();
match err {
Error::InvalidConfiguration { reason } => {
assert_eq!(reason, "ACS endpoint binding must be POST or Artifact");
}
other => panic!("expected InvalidConfiguration, got {other:?}"),
}
}
#[test]
fn decode_wire_post_round_trips_request() {
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
let xml = b"<samlp:AuthnRequest ID=\"_r\"/>";
let b64 = B64.encode(xml);
let decoded = decode_wire(b64.as_bytes(), Binding::HttpPost, WireDirection::Request)
.expect("decode_wire post");
assert_eq!(decoded.xml, xml);
assert!(decoded.relay_state.is_none());
assert!(decoded.detached_signature.is_none());
assert!(decoded.detached_sig_alg.is_none());
assert!(decoded.signed_query_string.is_none());
}
#[test]
fn decode_wire_post_round_trips_response() {
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
let xml = b"<samlp:Response ID=\"_x\"/>";
let b64 = B64.encode(xml);
let decoded = decode_wire(b64.as_bytes(), Binding::HttpPost, WireDirection::Response)
.expect("decode_wire post response");
assert_eq!(decoded.xml, xml);
}
#[test]
fn decode_wire_redirect_round_trips_unsigned() {
use url::Url;
let xml = b"<samlp:AuthnRequest ID=\"_r1\"/>";
let dest = Url::parse("https://idp.example.com/sso").unwrap();
let dispatch = redirect::encode_unsigned(
&dest,
redirect::RedirectDirection::Request,
xml,
Some("rs-token"),
)
.unwrap();
let Dispatch::Redirect(url) = dispatch else {
panic!("expected redirect dispatch");
};
let query = url.query().expect("query");
let decoded = decode_wire(
query.as_bytes(),
Binding::HttpRedirect,
WireDirection::Request,
)
.expect("decode_wire redirect");
assert_eq!(decoded.xml, xml);
assert_eq!(decoded.relay_state.as_deref(), Some("rs-token"));
assert!(decoded.detached_signature.is_none());
assert!(decoded.detached_sig_alg.is_none());
assert!(decoded.signed_query_string.is_none());
}
#[test]
fn decode_wire_redirect_surfaces_detached_signature_fields() {
use url::Url;
let xml = b"<samlp:AuthnRequest ID=\"_r2\"/>";
let dest = Url::parse("https://idp.example.com/sso").unwrap();
let sig_alg = "urn:test:alg";
let raw_signature_bytes = vec![0x42u8; 16];
let captured_signed_input: std::cell::RefCell<Option<Vec<u8>>> =
std::cell::RefCell::new(None);
let dispatch = redirect::encode_signed(
&dest,
redirect::RedirectDirection::Request,
xml,
Some("rs"),
sig_alg,
|signed_bytes| {
*captured_signed_input.borrow_mut() = Some(signed_bytes.to_vec());
Ok(raw_signature_bytes.clone())
},
)
.unwrap();
let Dispatch::Redirect(url) = dispatch else {
panic!("expected redirect dispatch");
};
let query = url.query().expect("query");
let decoded = decode_wire(
query.as_bytes(),
Binding::HttpRedirect,
WireDirection::Request,
)
.expect("decode_wire signed redirect");
assert_eq!(decoded.xml, xml);
assert_eq!(decoded.relay_state.as_deref(), Some("rs"));
assert_eq!(
decoded.detached_signature.as_deref(),
Some(raw_signature_bytes.as_slice()),
"raw signature bytes round-trip"
);
assert_eq!(decoded.detached_sig_alg.as_deref(), Some(sig_alg));
let signed_qs = decoded
.signed_query_string
.as_deref()
.expect("signed_query_string set on signed payload");
let captured = captured_signed_input.into_inner().expect("signer ran");
assert_eq!(
signed_qs.as_bytes(),
captured.as_slice(),
"canonical signed query string round-trips byte-for-byte",
);
}
#[test]
fn decode_wire_redirect_rejects_invalid_utf8_query() {
let err = decode_wire(
&[0xff, 0xfe, 0xfd],
Binding::HttpRedirect,
WireDirection::Request,
)
.unwrap_err();
assert!(matches!(err, Error::Base64Decode), "got {err:?}");
}
#[test]
fn decode_wire_soap_unsupported() {
let err = decode_wire(b"<x/>", Binding::Soap, WireDirection::Request).unwrap_err();
match err {
Error::UnsupportedByPeer { binding } => assert_eq!(binding, Binding::Soap),
other => panic!("expected UnsupportedByPeer(Soap), got {other:?}"),
}
}
#[test]
fn decode_wire_artifact_unsupported() {
let err = decode_wire(b"abc", Binding::HttpArtifact, WireDirection::Request).unwrap_err();
match err {
Error::UnsupportedByPeer { binding } => assert_eq!(binding, Binding::HttpArtifact),
other => panic!("expected UnsupportedByPeer(HttpArtifact), got {other:?}"),
}
}
}