#![cfg_attr(not(feature = "sso"), allow(unused_imports, dead_code))]
use std::sync::Arc;
use base64::Engine as _;
use gunnar_proto::{SamlSettings, SsoProvider};
#[cfg(feature = "sso")]
use nornir_auth_saml::sp;
use super::oidc::gunnar_core_paths;
use super::{Egress, Flow, SsoState, FLOW_TTL_MS, MAX_IDP_BODY, SKEW_SECS};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verified {
pub issuer: String,
pub subject: String,
pub user_name: String,
pub display_name: String,
pub email: String,
pub assertion_id: String,
pub not_on_or_after_unix: i64,
}
const TRANSIENT: &str = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient";
pub fn settings(provider: &SsoProvider) -> Result<&SamlSettings, String> {
provider
.saml
.as_ref()
.filter(|s| {
!s.idp_entity_id.trim().is_empty()
&& !s.idp_sso_url.trim().is_empty()
&& !s.idp_certificates.is_empty()
})
.ok_or_else(|| {
format!(
"{}'s SAML provider has no IdP entity id, sign-on URL or certificate",
provider.account
)
})
}
pub fn certificates(settings: &SamlSettings) -> Result<Vec<Vec<u8>>, String> {
settings
.idp_certificates
.iter()
.map(|c| {
base64::engine::general_purpose::STANDARD
.decode(c.trim())
.map_err(|e| format!("a stored IdP certificate does not decode: {e}"))
})
.collect()
}
pub fn fingerprint(der: &[u8]) -> String {
use sha2::Digest as _;
format!(
"SHA256:{}",
base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(der))
)
}
#[cfg(feature = "sso")]
pub async fn resolve_input(
state: &SsoState,
egress: Option<Arc<dyn Egress>>,
input: &SamlSettings,
) -> Result<SamlSettings, String> {
let mut out = SamlSettings {
idp_metadata_xml: String::new(),
idp_metadata_url: input.idp_metadata_url.trim().to_owned(),
idp_entity_id: input.idp_entity_id.trim().to_owned(),
idp_sso_url: input.idp_sso_url.trim().to_owned(),
idp_certificates: Vec::new(),
username_attribute: input.username_attribute.trim().to_owned(),
display_name_attribute: input.display_name_attribute.trim().to_owned(),
};
let mut metadata_xml = input.idp_metadata_xml.trim().to_owned();
if metadata_xml.is_empty() && !out.idp_metadata_url.is_empty() {
let url = out.idp_metadata_url.clone();
super::url_allowed_in(&url, state.allow_loopback_http(), &state.private_nets())?;
let egress =
egress.ok_or("this server reaches no identity provider (no egress is wired)")?;
let answer = tokio::task::spawn_blocking(move || egress.get(&url))
.await
.map_err(|e| format!("the metadata fetch did not finish: {e}"))?;
let (status, body) = answer.map_err(|e| format!("the IdP's metadata: {e}"))?;
if !(200..300).contains(&status) || body.len() > MAX_IDP_BODY {
return Err(format!(
"the IdP's metadata answered HTTP {status} ({} bytes)",
body.len()
));
}
metadata_xml = body;
}
let mut certs: Vec<Vec<u8>> = Vec::new();
if !metadata_xml.is_empty() {
let md = sp::parse_idp_metadata(&metadata_xml)
.map_err(|e| format!("the IdP's metadata: {e}"))?;
if out.idp_entity_id.is_empty() {
out.idp_entity_id = md.entity_id;
}
if out.idp_sso_url.is_empty() {
out.idp_sso_url = md.sso_redirect_url;
}
certs = md.certificates_der;
}
if !input.idp_certificates.is_empty() {
certs = input
.idp_certificates
.iter()
.filter(|c| !c.trim().is_empty())
.map(|c| {
sp::parse_certificate_pem_or_b64(c).map_err(|e| format!("an IdP certificate: {e}"))
})
.collect::<Result<_, _>>()?;
}
if out.idp_entity_id.is_empty() {
return Err("the IdP's entity id is required (paste its metadata, or name it)".to_owned());
}
if !(out.idp_sso_url.starts_with("https://")
|| state.allow_loopback_http() && out.idp_sso_url.starts_with("http://"))
{
return Err(format!(
"the IdP's sign-on URL {:?} is not an https URL",
out.idp_sso_url
));
}
if certs.is_empty() {
return Err("the IdP's signing certificate is required: a response is verified with it and nothing else".to_owned());
}
out.idp_certificates = certs
.iter()
.map(|der| base64::engine::general_purpose::STANDARD.encode(der))
.collect();
Ok(out)
}
#[cfg(feature = "sso")]
pub fn begin(provider: &SsoProvider, now: i64) -> Result<(String, String, Flow), String> {
let settings = settings(provider)?;
let state_token = nornir_auth_oidc::rp::random_token(&crate::webauthn::random_bytes(32));
let request_id = format!(
"_{}",
crate::webauthn::hex(&crate::webauthn::random_bytes(20))
);
let sp_entity_id = gunnar_core_paths::sp_entity_id(&provider.console_origin, &provider.account);
let acs = gunnar_core_paths::acs_url(&provider.console_origin);
let xml = sp::authn_request_xml(
&request_id,
&sp::format_instant(now / 1000),
&sp_entity_id,
&acs,
settings.idp_sso_url.trim(),
);
let url = sp::redirect_url(settings.idp_sso_url.trim(), &xml, &state_token)
.map_err(|e| e.to_string())?;
let flow = Flow {
account: provider.account.clone(),
saml: true,
console_origin: provider.console_origin.clone(),
expires_ms: now + FLOW_TTL_MS,
nonce: String::new(),
verifier: String::new(),
redirect_uri: acs,
request_id,
};
Ok((url, state_token, flow))
}
fn first(values: Option<&Vec<String>>) -> Option<String> {
values
.and_then(|v| v.first())
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
#[cfg(feature = "sso")]
pub fn finish(
state: &SsoState,
provider: &SsoProvider,
flow: &Flow,
saml_response: &str,
now: i64,
) -> Result<Verified, String> {
let settings = settings(provider)?;
let certs = certificates(settings)?;
let sp_entity_id = gunnar_core_paths::sp_entity_id(&provider.console_origin, &provider.account);
let acs = gunnar_core_paths::acs_url(&provider.console_origin);
let assertion = sp::verify_response_b64(
saml_response,
&sp::Expect {
idp_entity_id: settings.idp_entity_id.trim(),
sp_entity_id: &sp_entity_id,
acs_url: &acs,
request_id: Some(&flow.request_id),
now_unix: now / 1000,
skew_secs: SKEW_SECS,
certificates_der: &certs,
},
)
.map_err(|e| e.to_string())?;
if !state.first_sight(
&assertion.assertion_id,
assertion.not_on_or_after_unix.saturating_mul(1000),
now,
) {
return Err("this assertion was already used (a replay)".to_owned());
}
let attribute = settings.username_attribute.trim();
let from_attribute = (!attribute.is_empty())
.then(|| first(assertion.attributes.get(attribute)))
.flatten();
if !attribute.is_empty() && from_attribute.is_none() {
return Err(format!(
"the assertion carries no {attribute:?} attribute to name the person by"
));
}
let transient = assertion.name_id_format.as_deref() == Some(TRANSIENT);
let subject = if transient {
from_attribute.clone().ok_or_else(|| {
"the IdP sends a transient NameID; map the user-name attribute so the person can be recognised".to_owned()
})?
} else {
assertion.name_id.clone()
};
if subject.is_empty() || subject.len() > 512 {
return Err("the assertion names nobody".to_owned());
}
let user_name = from_attribute.unwrap_or_else(|| assertion.name_id.clone());
let display_name = match settings.display_name_attribute.trim() {
"" => first(assertion.attributes.get("displayName")).or_else(|| {
first(
assertion
.attributes
.get("http://schemas.microsoft.com/identity/claims/displayname"),
)
}),
a => first(assertion.attributes.get(a)),
}
.unwrap_or_default();
let email = first(assertion.attributes.get("email"))
.or_else(|| first(assertion.attributes.get("mail")))
.or_else(|| {
first(
assertion
.attributes
.get("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"),
)
})
.or_else(|| user_name.contains('@').then(|| user_name.clone()))
.unwrap_or_default();
Ok(Verified {
issuer: assertion.issuer,
subject,
user_name,
display_name,
email,
assertion_id: assertion.assertion_id,
not_on_or_after_unix: assertion.not_on_or_after_unix,
})
}
#[cfg(feature = "sso")]
pub fn sp_metadata(provider: &SsoProvider) -> String {
sp::sp_metadata_xml(
&gunnar_core_paths::sp_entity_id(&provider.console_origin, &provider.account),
&gunnar_core_paths::acs_url(&provider.console_origin),
)
}
#[cfg(not(feature = "sso"))]
mod off {
use super::*;
const NOT_BUILT: &str = "company SSO (OIDC/SAML) is not compiled into this build (gunnar-grpc feature `sso`)";
#[allow(clippy::unused_async)]
pub async fn resolve_input(_: &SsoState, _: Option<Arc<dyn Egress>>, _: &SamlSettings) -> Result<SamlSettings, String> {
Err(NOT_BUILT.to_owned())
}
pub fn begin(_: &SsoProvider, _: i64) -> Result<(String, String, Flow), String> {
Err(NOT_BUILT.to_owned())
}
pub fn finish(_: &SsoState, _: &SsoProvider, _: &Flow, _: &str, _: i64) -> Result<Verified, String> {
Err(NOT_BUILT.to_owned())
}
pub fn sp_metadata(_: &SsoProvider) -> String {
String::new()
}
}
#[cfg(not(feature = "sso"))]
pub use off::{begin, finish, resolve_input, sp_metadata};