use crate::{
auth::{AuthResult, AuthService, Permission, SamlResponse, User},
error::{FusekiError, FusekiResult},
server::AppState,
};
use axum::{
extract::{Form, Query, State},
http::{
header::{LOCATION, SET_COOKIE},
HeaderMap, StatusCode,
},
response::{Html, IntoResponse, Json, Redirect, Response},
};
use base64::{engine::general_purpose, Engine as _};
use chrono::{DateTime, Duration, Utc};
use flate2::{read::DeflateDecoder, write::DeflateEncoder, Compression};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{Read, Write};
use tracing::{debug, error, info, instrument, warn};
#[derive(Debug, Deserialize)]
pub struct SamlSsoParams {
pub target: Option<String>,
pub force_authn: Option<bool>,
pub idp_entity_id: Option<String>,
pub relay_state: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SamlAcsParams {
#[serde(rename = "SAMLResponse")]
pub saml_response: String,
#[serde(rename = "RelayState")]
pub relay_state: Option<String>,
#[serde(rename = "SigAlg")]
pub sig_alg: Option<String>,
#[serde(rename = "Signature")]
pub signature: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SamlSloParams {
#[serde(rename = "SAMLRequest")]
pub saml_request: Option<String>,
#[serde(rename = "SAMLResponse")]
pub saml_response: Option<String>,
#[serde(rename = "RelayState")]
pub relay_state: Option<String>,
#[serde(rename = "SigAlg")]
pub sig_alg: Option<String>,
#[serde(rename = "Signature")]
pub signature: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SamlAuthResponse {
pub success: bool,
pub sso_url: Option<String>,
pub relay_state: Option<String>,
pub request_id: Option<String>,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct SamlSsoResult {
pub success: bool,
pub user: Option<User>,
pub session_id: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct SamlMetadata {
pub entity_id: String,
pub sso_service_url: String,
pub acs_url: String,
pub slo_service_url: String,
pub certificate: String,
pub nameid_format: String,
pub signature_method: String,
pub digest_method: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlIdpConfig {
pub entity_id: String,
pub sso_url: String,
pub slo_url: Option<String>,
pub certificate: String,
pub name_id_format: String,
pub attribute_mapping: HashMap<String, String>,
pub signature_required: bool,
pub encryption_required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlSpConfig {
pub entity_id: String,
pub acs_url: String,
pub slo_url: String,
pub certificate: Option<String>,
pub private_key: Option<String>,
pub want_assertions_signed: bool,
pub want_authn_requests_signed: bool,
}
#[derive(Debug, Clone)]
pub struct ValidatedSamlAssertion {
pub subject: String,
pub issuer: String,
pub attributes: HashMap<String, Vec<String>>,
pub session_index: String,
pub not_on_or_after: DateTime<Utc>,
pub audience: String,
pub assertion_id: String,
pub signature_valid: bool,
pub conditions_valid: bool,
pub authn_context_class: Option<String>,
pub name_id_format: String,
pub encryption_valid: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlMfaRequirement {
pub required: bool,
pub accepted_contexts: Vec<String>,
pub minimum_strength: AuthnStrength,
pub timeout_minutes: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthnStrength {
Low, Medium, High, Highest, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnhancedSamlConfig {
pub sp_config: SamlSpConfig,
pub idp_configs: HashMap<String, SamlIdpConfig>,
pub mfa_requirements: SamlMfaRequirement,
pub session_config: SamlSessionConfig,
pub federation_config: SamlFederationConfig,
pub compliance_config: SamlComplianceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlSessionConfig {
pub max_session_duration_hours: u32,
pub idle_timeout_minutes: u32,
pub concurrent_sessions_allowed: u32,
pub session_fixation_protection: bool,
pub secure_cookie_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlFederationConfig {
pub enable_cross_domain: bool,
pub trusted_domains: Vec<String>,
pub metadata_refresh_interval_hours: u32,
pub discovery_service_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SamlComplianceConfig {
pub audit_all_assertions: bool,
pub require_encryption: bool,
pub minimum_signature_algorithm: String,
pub blacklisted_algorithms: Vec<String>,
pub require_destination_validation: bool,
}
#[instrument(skip(state))]
pub async fn initiate_saml_sso(
State(state): State<AppState>,
Query(params): Query<SamlSsoParams>,
) -> Result<Response, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or(FusekiError::service_unavailable(
"Authentication service not available",
))?;
if !auth_service.is_saml_enabled() {
return Err(FusekiError::service_unavailable(
"SAML authentication not configured",
));
}
let target_url = params.target.unwrap_or_else(|| "/".to_string());
let relay_state = params.relay_state.or_else(|| {
Some(
general_purpose::STANDARD.encode(
serde_json::json!({
"target": target_url,
"timestamp": Utc::now().timestamp()
})
.to_string(),
),
)
});
match auth_service.generate_saml_auth_request(relay_state).await {
Ok(sso_url) => {
info!("Generated SAML SSO request, redirecting to IdP");
Ok(Redirect::temporary(&sso_url).into_response())
}
Err(e) => {
error!("Failed to generate SAML SSO request: {}", e);
Err(e)
}
}
}
#[instrument(skip(state, form_data))]
pub async fn handle_saml_acs(
State(state): State<AppState>,
Form(form_data): Form<SamlAcsParams>,
) -> Result<Response, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or(FusekiError::service_unavailable(
"Authentication service not available",
))?;
debug!("Processing SAML ACS response");
match auth_service
.complete_saml_authentication(&form_data.saml_response, form_data.relay_state.as_deref())
.await
{
Ok(AuthResult::Authenticated(authenticated_user)) => {
info!(
"SAML authentication successful for user: {}",
authenticated_user.username
);
let session_id = auth_service
.create_session(authenticated_user.clone())
.await?;
let cookie_value = format!(
"session_id={}; HttpOnly; Secure; SameSite=Strict; Max-Age={}; Path=/",
session_id, state.config.security.session.timeout_secs
);
let redirect_url = extract_redirect_from_relay_state(&form_data.relay_state)
.unwrap_or_else(|| "/".to_string());
let mut response = Redirect::temporary(&redirect_url).into_response();
response.headers_mut().insert(
SET_COOKIE,
cookie_value
.parse()
.expect("cookie value should be valid header"),
);
Ok(response)
}
Ok(_) => {
warn!("SAML authentication failed");
Err(FusekiError::authentication("SAML authentication failed"))
}
Err(e) => {
error!("SAML authentication processing failed: {}", e);
Err(e)
}
}
}
#[instrument(skip(state))]
pub async fn handle_saml_slo(
State(state): State<AppState>,
Query(params): Query<SamlSloParams>,
) -> Result<Response, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or(FusekiError::service_unavailable(
"Authentication service not available",
))?;
if let Some(saml_request) = params.saml_request {
let decoded_request = decode_saml_request(&saml_request)?;
let (session_index, name_id) = extract_slo_info_from_request(&decoded_request)?;
let logout_success = auth_service.logout_by_session_index(&session_index).await?;
let slo_response = generate_saml_slo_response(&name_id, logout_success)?;
let encoded_response = encode_saml_response(&slo_response)?;
let slo_response_url = format!(
"{}?SAMLResponse={}&RelayState={}",
get_idp_slo_url(auth_service)?,
urlencoding::encode(&encoded_response),
params.relay_state.unwrap_or_default()
);
Ok(Redirect::temporary(&slo_response_url).into_response())
} else if let Some(saml_response) = params.saml_response {
let decoded_response = decode_saml_response(&saml_response)?;
if is_slo_response_successful(&decoded_response)? {
info!("SAML SLO completed successfully");
Ok(Html("<html><body><h1>Logout Successful</h1><p>You have been logged out.</p></body></html>").into_response())
} else {
warn!("SAML SLO failed");
Ok(Html("<html><body><h1>Logout Error</h1><p>Logout was not completed successfully.</p></body></html>").into_response())
}
} else {
Err(FusekiError::bad_request("Missing SAML request or response"))
}
}
#[instrument(skip(state))]
pub async fn get_saml_metadata(State(state): State<AppState>) -> Result<Response, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or(FusekiError::service_unavailable(
"Authentication service not available",
))?;
if !auth_service.is_saml_enabled() {
return Err(FusekiError::service_unavailable(
"SAML authentication not configured",
));
}
let saml_config = match auth_service.get_saml_sp_config() {
Ok(config) => config,
Err(_) => return Err(FusekiError::internal("SAML SP configuration not available")),
};
let metadata_xml = generate_saml_metadata(&saml_config)?;
Ok((
StatusCode::OK,
[("Content-Type", "application/samlmetadata+xml")],
metadata_xml,
)
.into_response())
}
#[instrument(skip(state))]
pub async fn initiate_saml_logout(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, FusekiError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or(FusekiError::service_unavailable(
"Authentication service not available",
))?;
let session_id = extract_session_id_from_headers(&headers)?;
let user = auth_service
.validate_session(&session_id)
.await?
.ok_or_else(|| FusekiError::authentication("Invalid session"))?;
let slo_request = generate_saml_slo_request(&user, &session_id)?;
let encoded_request = encode_saml_request(&slo_request)?;
let idp_slo_url = get_idp_slo_url(auth_service)?;
let logout_url = format!(
"{}?SAMLRequest={}&RelayState=logout",
idp_slo_url,
urlencoding::encode(&encoded_request)
);
auth_service.logout(&session_id).await?;
Ok(Redirect::temporary(&logout_url).into_response())
}
fn decode_saml_response(encoded_response: &str) -> FusekiResult<String> {
let decoded = general_purpose::STANDARD
.decode(encoded_response)
.map_err(|e| FusekiError::bad_request(format!("Invalid base64 encoding: {}", e)))?;
let mut decoder = DeflateDecoder::new(&decoded[..]);
let mut decompressed = String::new();
decoder.read_to_string(&mut decompressed).map_err(|e| {
FusekiError::bad_request(format!("Failed to decompress SAML response: {}", e))
})?;
Ok(decompressed)
}
fn decode_saml_request(encoded_request: &str) -> FusekiResult<String> {
decode_saml_response(encoded_request) }
fn encode_saml_response(response_xml: &str) -> FusekiResult<String> {
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(response_xml.as_bytes())
.map_err(|e| FusekiError::internal(format!("Failed to compress SAML response: {}", e)))?;
let compressed = encoder
.finish()
.map_err(|e| FusekiError::internal(format!("Failed to finish compression: {}", e)))?;
Ok(general_purpose::STANDARD.encode(compressed))
}
fn encode_saml_request(request_xml: &str) -> FusekiResult<String> {
encode_saml_response(request_xml) }
async fn validate_saml_assertion(
response_xml: &str,
auth_service: &AuthService,
) -> FusekiResult<ValidatedSamlAssertion> {
let subject = extract_xml_value(response_xml, "saml:Subject")?;
let issuer = extract_xml_value(response_xml, "saml:Issuer")?;
let session_index = extract_xml_value(response_xml, "saml:AuthnStatement")
.map(|stmt| extract_attribute(&stmt, "SessionIndex"))
.unwrap_or(None)
.unwrap_or_else(|| "unknown".to_string());
let attributes = extract_saml_attributes(response_xml)?;
let signature_valid = validate_saml_signature(response_xml, auth_service).await?;
let conditions_valid = validate_saml_conditions(response_xml)?;
Ok(ValidatedSamlAssertion {
subject: extract_name_id(&subject)?,
issuer,
attributes,
session_index,
not_on_or_after: Utc::now() + Duration::hours(8), audience: "oxirs-fuseki".to_string(),
assertion_id: generate_assertion_id(),
signature_valid,
conditions_valid,
authn_context_class: extract_authn_context_class(response_xml),
name_id_format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress".to_string(),
encryption_valid: validate_saml_encryption(response_xml, auth_service).await?,
})
}
async fn map_saml_attributes_to_user(
assertion: &ValidatedSamlAssertion,
auth_service: &AuthService,
) -> FusekiResult<User> {
let attribute_mapping = auth_service.get_saml_attribute_mapping()?;
let username = assertion.subject.clone();
let email = attribute_mapping
.email
.as_ref()
.and_then(|attr_name| assertion.attributes.get(attr_name))
.and_then(|values| values.first())
.cloned();
let full_name = attribute_mapping
.display_name
.as_ref()
.and_then(|attr_name| assertion.attributes.get(attr_name))
.and_then(|values| values.first())
.cloned();
let roles = attribute_mapping
.groups
.as_ref()
.and_then(|attr_name| assertion.attributes.get(attr_name))
.cloned()
.unwrap_or_else(|| vec!["user".to_string()]);
let permissions = generate_permissions_from_roles(&roles);
Ok(User {
username,
roles,
email,
full_name,
last_login: Some(Utc::now()),
permissions,
})
}
fn extract_redirect_from_relay_state(relay_state: &Option<String>) -> Option<String> {
relay_state.as_ref().and_then(|state| {
general_purpose::STANDARD
.decode(state)
.ok()
.and_then(|decoded| String::from_utf8(decoded).ok())
.and_then(|json_str| serde_json::from_str::<serde_json::Value>(&json_str).ok())
.and_then(|json| json.get("target")?.as_str().map(|s| s.to_string()))
})
}
fn generate_saml_metadata(
sp_config: &crate::auth::saml::ServiceProviderConfig,
) -> FusekiResult<String> {
let certificate_section = sp_config
.certificate
.as_ref()
.map(|cert| {
format!(
r#" <md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>"#,
cert
)
})
.unwrap_or_default();
let slo_section = sp_config.sls_url.as_ref()
.map(|url| format!(
r#" <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="{}"/>"#, url))
.unwrap_or_default();
let metadata = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="{}">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
{}
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="{}" index="1" isDefault="true"/>
{}
</md:SPSSODescriptor>
</md:EntityDescriptor>"#,
sp_config.entity_id, certificate_section, sp_config.acs_url, slo_section
);
Ok(metadata)
}
fn extract_xml_value(_xml: &str, _tag: &str) -> FusekiResult<String> {
Ok("mock_value".to_string())
}
fn extract_attribute(_text: &str, _attr: &str) -> Option<String> {
Some("mock_attribute".to_string())
}
fn extract_saml_attributes(_xml: &str) -> FusekiResult<HashMap<String, Vec<String>>> {
Ok(HashMap::new())
}
async fn validate_saml_signature(_xml: &str, _auth_service: &AuthService) -> FusekiResult<bool> {
Ok(true) }
fn validate_saml_conditions(_xml: &str) -> FusekiResult<bool> {
Ok(true) }
fn extract_name_id(subject: &str) -> FusekiResult<String> {
Ok(subject.to_string())
}
fn generate_assertion_id() -> String {
uuid::Uuid::new_v4().to_string()
}
fn generate_permissions_from_roles(roles: &[String]) -> Vec<Permission> {
let mut permissions = vec![Permission::SparqlQuery];
for role in roles {
match role.as_str() {
"admin" => {
permissions.extend(vec![
Permission::GlobalAdmin,
Permission::SparqlUpdate,
Permission::UserManagement,
Permission::SystemConfig,
]);
}
"writer" => {
permissions.push(Permission::SparqlUpdate);
}
"reader" => {
permissions.push(Permission::GlobalRead);
}
_ => {}
}
}
permissions
}
fn extract_slo_info_from_request(_xml: &str) -> FusekiResult<(String, String)> {
Ok(("session123".to_string(), "user@example.com".to_string()))
}
fn generate_saml_slo_response(_name_id: &str, _success: bool) -> FusekiResult<String> {
Ok("<samlp:LogoutResponse>...</samlp:LogoutResponse>".to_string())
}
fn generate_saml_slo_request(_user: &User, _session_id: &str) -> FusekiResult<String> {
Ok("<samlp:LogoutRequest>...</samlp:LogoutRequest>".to_string())
}
fn get_idp_slo_url(_auth_service: &AuthService) -> FusekiResult<String> {
Ok("https://idp.example.com/slo".to_string())
}
fn is_slo_response_successful(_xml: &str) -> FusekiResult<bool> {
Ok(true)
}
fn extract_session_id_from_headers(_headers: &HeaderMap) -> FusekiResult<String> {
Ok("session123".to_string())
}
fn extract_authn_context_class(_xml: &str) -> Option<String> {
Some("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport".to_string())
}
async fn validate_saml_encryption(_xml: &str, _auth_service: &AuthService) -> FusekiResult<bool> {
Ok(true)
}
pub async fn validate_enhanced_saml_assertion(
response_xml: &str,
auth_service: &AuthService,
compliance_config: &SamlComplianceConfig,
) -> FusekiResult<ValidatedSamlAssertion> {
let assertion = validate_saml_assertion(response_xml, auth_service).await?;
if compliance_config.require_encryption && !assertion.encryption_valid {
return Err(FusekiError::authentication(
"SAML assertion encryption required but not valid",
));
}
if compliance_config.require_destination_validation
&& !validate_assertion_destination(response_xml)?
{
return Err(FusekiError::authentication(
"SAML assertion destination validation failed",
));
}
let signature_algorithm = extract_signature_algorithm(response_xml)?;
if compliance_config
.blacklisted_algorithms
.contains(&signature_algorithm)
{
return Err(FusekiError::authentication(
"SAML signature algorithm not allowed",
));
}
if !meets_minimum_signature_strength(
&signature_algorithm,
&compliance_config.minimum_signature_algorithm,
)? {
return Err(FusekiError::authentication(
"SAML signature algorithm too weak",
));
}
Ok(assertion)
}
pub fn validate_mfa_requirements(
assertion: &ValidatedSamlAssertion,
mfa_config: &SamlMfaRequirement,
) -> FusekiResult<bool> {
if !mfa_config.required {
return Ok(true);
}
let authn_context = assertion.authn_context_class.as_ref().ok_or_else(|| {
FusekiError::authentication("MFA required but no authentication context provided")
})?;
if !mfa_config.accepted_contexts.contains(authn_context) {
return Err(FusekiError::authentication(
"Authentication context does not meet MFA requirements",
));
}
let context_strength = determine_authn_strength(authn_context);
if !meets_minimum_strength(&context_strength, &mfa_config.minimum_strength) {
return Err(FusekiError::authentication(
"Authentication strength insufficient for MFA requirements",
));
}
Ok(true)
}
fn validate_assertion_destination(_xml: &str) -> FusekiResult<bool> {
Ok(true)
}
fn extract_signature_algorithm(_xml: &str) -> FusekiResult<String> {
Ok("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256".to_string())
}
fn meets_minimum_signature_strength(algorithm: &str, minimum: &str) -> FusekiResult<bool> {
let strength_order = [
"http://www.w3.org/2000/09/xmldsig#rsa-sha1", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", ];
let alg_pos = strength_order
.iter()
.position(|&x| x == algorithm)
.unwrap_or(0);
let min_pos = strength_order
.iter()
.position(|&x| x == minimum)
.unwrap_or(0);
Ok(alg_pos >= min_pos)
}
pub fn determine_authn_strength(context_class: &str) -> AuthnStrength {
match context_class {
"urn:oasis:names:tc:SAML:2.0:ac:classes:Password" => AuthnStrength::Low,
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" => AuthnStrength::Low,
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileOneFactorUnregistered" => {
AuthnStrength::Medium
}
"urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract" => AuthnStrength::High,
"urn:oasis:names:tc:SAML:2.0:ac:classes:Smartcard" => AuthnStrength::High,
"urn:oasis:names:tc:SAML:2.0:ac:classes:SmartcardPKI" => AuthnStrength::Highest,
_ => AuthnStrength::Low,
}
}
pub fn meets_minimum_strength(actual: &AuthnStrength, required: &AuthnStrength) -> bool {
let strength_values = |s: &AuthnStrength| match s {
AuthnStrength::Low => 1,
AuthnStrength::Medium => 2,
AuthnStrength::High => 3,
AuthnStrength::Highest => 4,
};
strength_values(actual) >= strength_values(required)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_saml_response_decoding() {
let encoded = general_purpose::STANDARD.encode("test response");
}
#[test]
fn test_permission_generation_from_roles() {
let admin_roles = vec!["admin".to_string()];
let permissions = generate_permissions_from_roles(&admin_roles);
assert!(permissions.contains(&Permission::GlobalAdmin));
assert!(permissions.contains(&Permission::SparqlUpdate));
}
#[test]
fn test_authn_strength_comparison() {
assert!(meets_minimum_strength(
&AuthnStrength::High,
&AuthnStrength::Medium
));
assert!(!meets_minimum_strength(
&AuthnStrength::Low,
&AuthnStrength::High
));
assert!(meets_minimum_strength(
&AuthnStrength::Highest,
&AuthnStrength::Highest
));
}
}