use crate::errors::{AuthError, Result};
use crate::server::oidc::oidc_session_management::{OidcSession, SessionManager};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::SystemTime;
use tokio::time::Duration;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackChannelLogoutRequest {
pub session_id: String,
pub sub: String,
pub sid: Option<String>,
pub iss: String,
pub initiating_client_id: Option<String>,
pub additional_events: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackChannelLogoutResponse {
pub success: bool,
pub notified_rps: usize,
pub successful_notifications: Vec<NotificationResult>,
pub failed_notifications: Vec<FailedNotification>,
pub logout_token_jti: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationResult {
pub client_id: String,
pub backchannel_logout_uri: String,
pub success: bool,
pub status_code: Option<u16>,
pub retry_attempts: u32,
pub response_time_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailedNotification {
pub client_id: String,
pub backchannel_logout_uri: String,
pub error: String,
pub status_code: Option<u16>,
pub retry_attempts: u32,
}
#[derive(Debug, Clone)]
pub struct BackChannelLogoutConfig {
pub enabled: bool,
pub base_url: Option<String>,
pub request_timeout_secs: u64,
pub max_retry_attempts: u32,
pub retry_delay_ms: u64,
pub max_concurrent_notifications: usize,
pub logout_token_exp_secs: u64,
pub include_session_claims: bool,
pub user_agent: String,
pub enable_http_logging: bool,
}
impl Default for BackChannelLogoutConfig {
fn default() -> Self {
Self {
enabled: true,
base_url: None,
request_timeout_secs: 30,
max_retry_attempts: 3,
retry_delay_ms: 1000, max_concurrent_notifications: 10,
logout_token_exp_secs: 120, include_session_claims: true,
user_agent: "AuthFramework-OIDC/1.0".to_string(),
enable_http_logging: false,
}
}
}
#[derive(Debug, Clone)]
pub struct RpBackChannelConfig {
pub client_id: String,
pub backchannel_logout_uri: String,
pub backchannel_logout_session_required: bool,
pub custom_timeout_secs: Option<u64>,
pub custom_max_retries: Option<u32>,
pub authentication_method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoutTokenClaims {
pub iss: String,
pub sub: Option<String>,
pub aud: Vec<String>,
pub iat: u64,
pub jti: String,
pub events: LogoutEvents,
pub sid: Option<String>,
pub exp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoutEvents {
#[serde(
rename = "http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
)]
pub backchannel_logout: Option<serde_json::Value>,
#[serde(rename = "http://schemas.openid.net/secevent/oauth/event-type/token-revocation")]
pub token_revocation: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct BackChannelLogoutManager {
config: BackChannelLogoutConfig,
session_manager: SessionManager,
http_client: crate::server::core::common_http::HttpClient,
rp_configs: HashMap<String, RpBackChannelConfig>,
active_logouts: HashMap<String, SystemTime>,
}
impl BackChannelLogoutManager {
pub fn new(config: BackChannelLogoutConfig, session_manager: SessionManager) -> Result<Self> {
use crate::server::core::common_config::{EndpointConfig, SecurityConfig, TimeoutConfig};
let mut endpoint_config = EndpointConfig::new(
config
.base_url
.as_ref()
.unwrap_or(&"http://localhost:8080".to_string()),
);
endpoint_config.timeout = TimeoutConfig {
connect_timeout: Duration::from_secs(config.request_timeout_secs),
read_timeout: Duration::from_secs(config.request_timeout_secs),
write_timeout: Duration::from_secs(30),
};
endpoint_config.security = SecurityConfig {
enable_tls: true,
min_tls_version: "1.2".to_string(),
cipher_suites: vec![
"TLS_AES_256_GCM_SHA384".to_string(),
"TLS_CHACHA20_POLY1305_SHA256".to_string(),
"TLS_AES_128_GCM_SHA256".to_string(),
],
cert_validation: crate::server::core::common_config::CertificateValidation::Full,
verify_certificates: true,
};
endpoint_config
.headers
.insert("User-Agent".to_string(), config.user_agent.clone());
let http_client = crate::server::core::common_http::HttpClient::new(endpoint_config)?;
Ok(Self {
config,
session_manager,
http_client,
rp_configs: HashMap::new(),
active_logouts: HashMap::new(),
})
}
pub fn register_rp_config(&mut self, rp_config: RpBackChannelConfig) {
self.rp_configs
.insert(rp_config.client_id.clone(), rp_config);
}
pub async fn process_backchannel_logout(
&mut self,
request: BackChannelLogoutRequest,
) -> Result<BackChannelLogoutResponse> {
if !self.config.enabled {
return Err(AuthError::validation("Back-channel logout is not enabled"));
}
let user_sessions = self.session_manager.get_sessions_for_subject(&request.sub);
let mut rps_to_notify = Vec::new();
for session in user_sessions {
if session.session_id == request.session_id {
continue;
}
if let Some(rp_config) = self.rp_configs.get(&session.client_id) {
if let Some(ref initiating_client) = request.initiating_client_id
&& &session.client_id == initiating_client
{
continue;
}
rps_to_notify.push((session.clone(), rp_config.clone()));
}
}
let logout_token_jti = Uuid::new_v4().to_string();
let logout_token = self
.generate_logout_token(&request, &logout_token_jti)
.map_err(|e| {
AuthError::validation(format!("Failed to generate logout token: {}", e))
})?;
let mut successful_notifications = Vec::new();
let mut failed_notifications = Vec::new();
let chunk_size = self.config.max_concurrent_notifications;
for chunk in rps_to_notify.chunks(chunk_size) {
let mut tasks = Vec::new();
for (session, rp_config) in chunk {
let logout_token_clone = logout_token.clone();
let rp_config_clone = rp_config.clone();
let session_clone = session.clone();
let client_clone = self.http_client.clone();
let config_clone = self.config.clone();
let task = tokio::spawn(async move {
Self::send_backchannel_notification(
client_clone,
config_clone,
session_clone,
rp_config_clone,
logout_token_clone,
)
.await
});
tasks.push(task);
}
for task in tasks {
match task.await {
Ok(Ok(notification_result)) => {
successful_notifications.push(notification_result);
}
Ok(Err(failed_notification)) => {
failed_notifications.push(failed_notification);
}
Err(e) => {
failed_notifications.push(FailedNotification {
client_id: "unknown".to_string(),
backchannel_logout_uri: "unknown".to_string(),
error: format!("Task execution failed: {}", e),
status_code: None,
retry_attempts: 0,
});
}
}
}
}
self.active_logouts
.insert(logout_token_jti.clone(), SystemTime::now());
Ok(BackChannelLogoutResponse {
success: failed_notifications.is_empty(),
notified_rps: successful_notifications.len(),
successful_notifications,
failed_notifications,
logout_token_jti,
})
}
fn generate_logout_token(
&self,
request: &BackChannelLogoutRequest,
jti: &str,
) -> Result<String> {
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let now = chrono::Utc::now().timestamp();
let mut events = serde_json::json!({
"http://schemas.openid.net/secevent/oauth/event-type/logout": {}
});
if let Some(ref additional_events) = request.additional_events {
for (event_type, event_data) in additional_events {
let validated_event = serde_from_value::<serde_json::Value>(event_data.clone())?;
events[event_type] = validated_event;
}
}
let claims = serde_json::json!({
"iss": request.iss,
"sub": request.sub,
"aud": request.initiating_client_id.as_ref().unwrap_or(&"default_client".to_string()),
"iat": now,
"jti": jti,
"events": events,
});
let header = serde_json::json!({
"alg": "RS256",
"typ": "logout+jwt",
});
let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let signing_input = format!("{}.{}", header_b64, claims_b64);
let signature = self.generate_logout_token_signature(&signing_input)?;
let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
Ok(format!("{}.{}.{}", header_b64, claims_b64, signature_b64))
}
fn generate_logout_token_signature(&self, signing_input: &str) -> Result<Vec<u8>> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(signing_input.as_bytes());
hasher.update(b"logout_token_signature_salt");
Ok(hasher.finalize().to_vec())
}
async fn send_backchannel_notification(
client: crate::server::core::common_http::HttpClient,
config: BackChannelLogoutConfig,
session: OidcSession,
rp_config: RpBackChannelConfig,
logout_token: String,
) -> Result<NotificationResult, FailedNotification> {
use std::collections::HashMap;
let client_id = session.client_id.clone();
let backchannel_logout_uri = rp_config.backchannel_logout_uri.clone();
let mut form_data = HashMap::new();
form_data.insert("logout_token".to_string(), logout_token);
let mut retry_count = 0;
let max_retries = config.max_retry_attempts;
let start_time = std::time::Instant::now();
loop {
let response = client.post_form(&backchannel_logout_uri, &form_data).await;
match response {
Ok(resp) => {
let status_code = resp.status().as_u16();
let response_time = start_time.elapsed().as_millis() as u64;
if resp.status().is_success() {
return Ok(NotificationResult {
client_id,
backchannel_logout_uri,
success: true,
status_code: Some(status_code),
retry_attempts: retry_count,
response_time_ms: response_time,
});
} else if retry_count < max_retries && Self::is_retryable_status(status_code) {
retry_count += 1;
let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
tokio::time::sleep(delay).await;
continue;
} else {
let body = resp.text().await.unwrap_or_default();
return Err(FailedNotification {
client_id,
backchannel_logout_uri,
error: format!("HTTP {}: {}", status_code, body),
status_code: Some(status_code),
retry_attempts: retry_count,
});
}
}
Err(e) => {
if retry_count < max_retries {
retry_count += 1;
let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
tokio::time::sleep(delay).await;
continue;
} else {
return Err(FailedNotification {
client_id,
backchannel_logout_uri,
error: format!("Request failed: {}", e),
status_code: None,
retry_attempts: retry_count,
});
}
}
}
}
}
fn is_retryable_status(status_code: u16) -> bool {
match status_code {
429 => true,
408 => true,
500..=599 => true,
_ => false,
}
}
pub fn cleanup_expired_logouts(&mut self) -> usize {
let now = SystemTime::now();
let initial_count = self.active_logouts.len();
self.active_logouts.retain(|_, timestamp| {
now.duration_since(*timestamp)
.map(|d| d.as_secs() < 3600) .unwrap_or(false)
});
initial_count - self.active_logouts.len()
}
pub fn get_discovery_metadata(&self) -> HashMap<String, serde_json::Value> {
let mut metadata = HashMap::new();
if self.config.enabled {
metadata.insert(
"backchannel_logout_supported".to_string(),
serde_json::Value::Bool(true),
);
metadata.insert(
"backchannel_logout_session_supported".to_string(),
serde_json::Value::Bool(self.config.include_session_claims),
);
}
metadata
}
}
fn serde_from_value<T>(value: serde_json::Value) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
serde_json::from_value(value)
.map_err(|e| AuthError::internal(format!("JSON deserialization error: {}", e)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::oidc::oidc_session_management::SessionManagementConfig;
fn create_test_manager() -> Result<BackChannelLogoutManager> {
let config = BackChannelLogoutConfig::default();
let session_manager = SessionManager::new(SessionManagementConfig::default());
BackChannelLogoutManager::new(config, session_manager)
}
#[test]
fn test_retryable_status_codes() {
assert!(BackChannelLogoutManager::is_retryable_status(500));
assert!(BackChannelLogoutManager::is_retryable_status(502));
assert!(BackChannelLogoutManager::is_retryable_status(503));
assert!(BackChannelLogoutManager::is_retryable_status(429));
assert!(!BackChannelLogoutManager::is_retryable_status(400));
assert!(!BackChannelLogoutManager::is_retryable_status(401));
assert!(!BackChannelLogoutManager::is_retryable_status(404));
assert!(!BackChannelLogoutManager::is_retryable_status(200));
assert!(!BackChannelLogoutManager::is_retryable_status(204));
}
#[test]
fn test_logout_token_generation() -> Result<()> {
let manager = create_test_manager()?;
let request = BackChannelLogoutRequest {
session_id: "session123".to_string(),
sub: "user123".to_string(),
sid: Some("sid123".to_string()),
iss: "https://op.example.com".to_string(),
initiating_client_id: None,
additional_events: None,
};
let token = manager.generate_logout_token(&request, "jti123")?;
assert!(!token.is_empty());
assert_eq!(token.split('.').count(), 3);
Ok(())
}
#[test]
fn test_logout_token_with_additional_events() -> Result<()> {
let manager = create_test_manager()?;
let mut additional_events = HashMap::new();
additional_events.insert(
"http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
.to_string(),
serde_json::json!({
"reason": "password_change",
"timestamp": "2025-08-07T12:00:00Z"
}),
);
additional_events.insert(
"custom-event-type".to_string(),
serde_json::json!({
"custom_field": "custom_value"
}),
);
let request = BackChannelLogoutRequest {
session_id: "session123".to_string(),
sub: "user123".to_string(),
sid: Some("sid123".to_string()),
iss: "https://op.example.com".to_string(),
initiating_client_id: Some("client_456".to_string()),
additional_events: Some(additional_events),
};
let token = manager.generate_logout_token(&request, "jti456")?;
assert!(!token.is_empty());
assert_eq!(token.split('.').count(), 3);
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let parts: Vec<&str> = token.split('.').collect();
assert_eq!(parts.len(), 3);
let claims_json = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
let claims: serde_json::Value = serde_json::from_str(&claims_json).unwrap();
let events = &claims["events"];
assert!(events["http://schemas.openid.net/secevent/oauth/event-type/logout"].is_object());
assert!(events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"].is_object());
assert!(events["custom-event-type"].is_object());
assert_eq!(
events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"]
["reason"],
"password_change"
);
assert_eq!(events["custom-event-type"]["custom_field"], "custom_value");
Ok(())
}
#[test]
fn test_discovery_metadata() -> Result<()> {
let manager = create_test_manager()?;
let metadata = manager.get_discovery_metadata();
assert_eq!(
metadata.get("backchannel_logout_supported"),
Some(&serde_json::Value::Bool(true))
);
assert_eq!(
metadata.get("backchannel_logout_session_supported"),
Some(&serde_json::Value::Bool(true))
);
Ok(())
}
}