use core::fmt;
use crate::util::validation::is_https_url;
#[doc(alias = "saml_config_error")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SamlConfigError {
field: &'static str,
reason: &'static str,
}
impl fmt::Display for SamlConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "saml config: {} {}", self.field, self.reason)
}
}
impl std::error::Error for SamlConfigError {}
#[doc(alias = "saml_config")]
#[derive(Debug, Clone)]
pub struct SamlConfig {
entity_id: String,
acs_url: String,
idp_sso_url: String,
idp_entity_id: String,
clock_skew_secs: u64,
}
impl SamlConfig {
#[must_use]
#[inline]
pub fn entity_id(&self) -> &str {
&self.entity_id
}
#[must_use]
#[inline]
pub fn acs_url(&self) -> &str {
&self.acs_url
}
#[must_use]
#[inline]
pub fn idp_sso_url(&self) -> &str {
&self.idp_sso_url
}
#[must_use]
#[inline]
pub fn idp_entity_id(&self) -> &str {
&self.idp_entity_id
}
#[must_use]
#[inline]
pub fn clock_skew_secs(&self) -> u64 {
self.clock_skew_secs
}
#[must_use]
pub fn with_clock_skew_secs(mut self, secs: u64) -> Self {
self.clock_skew_secs = secs;
self
}
}
fn validate_not_empty(value: &str, field: &'static str) -> Result<(), SamlConfigError> {
if value.is_empty() {
return Err(SamlConfigError {
field,
reason: "must not be empty",
});
}
Ok(())
}
fn validate_https_url(value: &str, field: &'static str) -> Result<(), SamlConfigError> {
validate_not_empty(value, field)?;
if !is_https_url(value) {
return Err(SamlConfigError {
field,
reason: "must use HTTPS",
});
}
Ok(())
}
impl SamlConfig {
pub fn new(
entity_id: &str,
acs_url: &str,
idp_sso_url: &str,
idp_entity_id: &str,
) -> Result<Self, SamlConfigError> {
validate_not_empty(entity_id, "entity_id")?;
validate_https_url(acs_url, "acs_url")?;
validate_https_url(idp_sso_url, "idp_sso_url")?;
validate_not_empty(idp_entity_id, "idp_entity_id")?;
Ok(Self {
entity_id: entity_id.to_string(),
acs_url: acs_url.to_string(),
idp_sso_url: idp_sso_url.to_string(),
idp_entity_id: idp_entity_id.to_string(),
clock_skew_secs: 60,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_round_trip() {
let cfg = SamlConfig::new(
"https://sp.example.com",
"https://sp.example.com/acs",
"https://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap();
assert_eq!(cfg.entity_id(), "https://sp.example.com");
assert_eq!(cfg.acs_url(), "https://sp.example.com/acs");
assert_eq!(cfg.idp_sso_url(), "https://idp.example.com/sso");
assert_eq!(cfg.idp_entity_id(), "https://idp.example.com");
}
#[test]
fn reject_empty_entity_id() {
let err = SamlConfig::new(
"",
"https://sp.example.com/acs",
"https://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap_err();
assert!(err.to_string().contains("entity_id"), "got: {err}");
}
#[test]
fn reject_empty_acs_url() {
let err = SamlConfig::new(
"https://sp.example.com",
"",
"https://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap_err();
assert!(err.to_string().contains("acs_url"), "got: {err}");
}
#[test]
fn reject_empty_idp_sso_url() {
let err = SamlConfig::new(
"https://sp.example.com",
"https://sp.example.com/acs",
"",
"https://idp.example.com",
)
.unwrap_err();
assert!(err.to_string().contains("idp_sso_url"), "got: {err}");
}
#[test]
fn reject_empty_idp_entity_id() {
let err = SamlConfig::new(
"https://sp.example.com",
"https://sp.example.com/acs",
"https://idp.example.com/sso",
"",
)
.unwrap_err();
assert!(err.to_string().contains("idp_entity_id"), "got: {err}");
}
#[test]
fn reject_http_acs_url() {
let err = SamlConfig::new(
"https://sp.example.com",
"http://sp.example.com/acs",
"https://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap_err();
assert!(err.to_string().contains("HTTPS"), "got: {err}");
}
#[test]
fn reject_http_idp_sso_url() {
let err = SamlConfig::new(
"https://sp.example.com",
"https://sp.example.com/acs",
"http://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap_err();
assert!(err.to_string().contains("HTTPS"), "got: {err}");
}
#[test]
fn error_display_format() {
let err = SamlConfigError {
field: "acs_url",
reason: "must use HTTPS",
};
assert_eq!(err.to_string(), "saml config: acs_url must use HTTPS");
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> = Box::new(SamlConfigError {
field: "test",
reason: "test reason",
});
let _ = err.to_string();
}
#[test]
fn default_clock_skew_is_60() {
let cfg = SamlConfig::new(
"https://sp.example.com",
"https://sp.example.com/acs",
"https://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap();
assert_eq!(cfg.clock_skew_secs(), 60);
}
#[test]
fn with_clock_skew_secs_overrides_default() {
let cfg = SamlConfig::new(
"https://sp.example.com",
"https://sp.example.com/acs",
"https://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap()
.with_clock_skew_secs(120);
assert_eq!(cfg.clock_skew_secs(), 120);
}
#[test]
fn with_clock_skew_secs_zero() {
let cfg = SamlConfig::new(
"https://sp.example.com",
"https://sp.example.com/acs",
"https://idp.example.com/sso",
"https://idp.example.com",
)
.unwrap()
.with_clock_skew_secs(0);
assert_eq!(cfg.clock_skew_secs(), 0);
}
}