use std::collections::BTreeMap;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use super::error::FederationError;
use super::types::{AttestationContent, IdpId, LifecycleClaim, Nonce};
#[derive(Debug, Clone)]
pub struct SamlAssertion {
pub issuer: String,
pub name_id: String,
pub audiences: Vec<String>,
pub not_before: Option<DateTime<Utc>>,
pub not_on_or_after: Option<DateTime<Utc>>,
pub assertion_id: String,
pub attributes: BTreeMap<String, Vec<String>>,
}
#[async_trait]
pub trait SamlAssertionVerifier: Send + Sync {
async fn verify(
&self,
response_xml: &[u8],
now: DateTime<Utc>,
) -> Result<SamlAssertion, FederationError>;
}
#[derive(Debug, Clone)]
pub struct SamlAttestationRequest {
pub subject: auths_verifier::IdentityDID,
pub claim: LifecycleClaim,
pub expected_audience: String,
pub ttl_secs: i64,
}
const GROUP_ATTRIBUTE_NAMES: &[&str] = &[
"groups",
"memberOf",
"http://schemas.xmlsoap.org/claims/Group",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/groups",
];
pub async fn verify_saml_attestation(
verifier: &dyn SamlAssertionVerifier,
response_xml: &[u8],
request: &SamlAttestationRequest,
now: DateTime<Utc>,
) -> Result<AttestationContent, FederationError> {
let assertion = verifier.verify(response_xml, now).await?;
if !assertion
.audiences
.iter()
.any(|a| a == &request.expected_audience)
{
return Err(FederationError::ClaimNotInToken(format!(
"audience '{}' not in assertion AudienceRestriction",
request.expected_audience
)));
}
if let Some(not_before) = assertion.not_before
&& now < not_before
{
return Err(FederationError::TokenInvalid(
"SAML assertion is not yet valid (NotBefore)".to_string(),
));
}
if let Some(not_on_or_after) = assertion.not_on_or_after
&& now >= not_on_or_after
{
return Err(FederationError::TokenInvalid(
"SAML assertion has expired (NotOnOrAfter)".to_string(),
));
}
validate_saml_claim(&request.claim, &assertion)?;
let idp = IdpId::new(&assertion.issuer)?;
let nonce = Nonce::new(&assertion.assertion_id)?;
Ok(AttestationContent {
subject: request.subject.clone(),
idp,
claim: request.claim.clone(),
nonce,
expires_at: now + Duration::seconds(request.ttl_secs),
})
}
pub async fn attest_saml(
ctx: &crate::context::AuthsContext,
verifier: &dyn SamlAssertionVerifier,
response_xml: &[u8],
request: &SamlAttestationRequest,
subject_alias: &auths_core::storage::keychain::KeyAlias,
now: DateTime<Utc>,
) -> Result<super::types::IdpAttestation, FederationError> {
let content = verify_saml_attestation(verifier, response_xml, request, now).await?;
let subject_prefix = auths_id::keri::parse_did_keri(content.subject.as_str())
.map_err(|e| FederationError::InvalidSubject(e.to_string()))?;
super::anchor::anchor_attestation(ctx, &subject_prefix, subject_alias, &content, now)
}
fn validate_saml_claim(
claim: &LifecycleClaim,
assertion: &SamlAssertion,
) -> Result<(), FederationError> {
if let LifecycleClaim::GroupMember(group) = claim {
let present = GROUP_ATTRIBUTE_NAMES.iter().any(|name| {
assertion
.attributes
.get(*name)
.is_some_and(|values| values.iter().any(|v| v == group.as_str()))
});
if !present {
return Err(FederationError::ClaimNotInToken(format!(
"group '{}' not present in the SAML assertion's group attributes",
group.as_str()
)));
}
}
Ok(())
}