1use crate::errors::{AuthError, Result};
16use crate::server::oidc::oidc_session_management::{OidcSession, SessionManager};
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::time::SystemTime;
20use tokio::time::Duration;
21use uuid::Uuid;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BackChannelLogoutRequest {
26 pub session_id: String,
28 pub sub: String,
30 pub sid: Option<String>,
32 pub iss: String,
34 pub initiating_client_id: Option<String>,
36 pub additional_events: Option<HashMap<String, serde_json::Value>>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BackChannelLogoutResponse {
43 pub success: bool,
45 pub notified_rps: usize,
47 pub successful_notifications: Vec<NotificationResult>,
49 pub failed_notifications: Vec<FailedNotification>,
51 pub logout_token_jti: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct NotificationResult {
58 pub client_id: String,
60 pub backchannel_logout_uri: String,
62 pub success: bool,
64 pub status_code: Option<u16>,
66 pub retry_attempts: u32,
68 pub response_time_ms: u64,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct FailedNotification {
75 pub client_id: String,
77 pub backchannel_logout_uri: String,
79 pub error: String,
81 pub status_code: Option<u16>,
83 pub retry_attempts: u32,
85}
86
87#[derive(Debug, Clone)]
89pub struct BackChannelLogoutConfig {
90 pub enabled: bool,
92 pub base_url: Option<String>,
94 pub request_timeout_secs: u64,
96 pub max_retry_attempts: u32,
98 pub retry_delay_ms: u64,
100 pub max_concurrent_notifications: usize,
102 pub logout_token_exp_secs: u64,
104 pub include_session_claims: bool,
106 pub user_agent: String,
108 pub enable_http_logging: bool,
110}
111
112impl Default for BackChannelLogoutConfig {
113 fn default() -> Self {
114 Self {
115 enabled: true,
116 base_url: None,
117 request_timeout_secs: 30,
118 max_retry_attempts: 3,
119 retry_delay_ms: 1000, max_concurrent_notifications: 10,
121 logout_token_exp_secs: 120, include_session_claims: true,
123 user_agent: "AuthFramework-OIDC/1.0".to_string(),
124 enable_http_logging: false,
125 }
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct RpBackChannelConfig {
132 pub client_id: String,
134 pub backchannel_logout_uri: String,
136 pub backchannel_logout_session_required: bool,
138 pub custom_timeout_secs: Option<u64>,
140 pub custom_max_retries: Option<u32>,
142 pub authentication_method: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct LogoutTokenClaims {
149 pub iss: String,
151 pub sub: Option<String>,
153 pub aud: Vec<String>,
155 pub iat: u64,
157 pub jti: String,
159 pub events: LogoutEvents,
161 pub sid: Option<String>,
163 pub exp: u64,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct LogoutEvents {
170 #[serde(
172 rename = "http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
173 )]
174 pub backchannel_logout: Option<serde_json::Value>,
175
176 #[serde(rename = "http://schemas.openid.net/secevent/oauth/event-type/token-revocation")]
178 pub token_revocation: Option<serde_json::Value>,
179}
180
181#[derive(Debug)]
183pub struct BackChannelLogoutManager {
184 config: BackChannelLogoutConfig,
186 session_manager: SessionManager,
188 http_client: crate::server::core::common_http::HttpClient,
190 rp_configs: HashMap<String, RpBackChannelConfig>,
192 active_logouts: HashMap<String, SystemTime>,
194}
195
196impl BackChannelLogoutManager {
197 pub fn new(config: BackChannelLogoutConfig, session_manager: SessionManager) -> Result<Self> {
199 use crate::server::core::common_config::{EndpointConfig, SecurityConfig, TimeoutConfig};
200
201 let mut endpoint_config = EndpointConfig::new(
203 config
204 .base_url
205 .as_ref()
206 .unwrap_or(&"http://localhost:8080".to_string()),
207 );
208 endpoint_config.timeout = TimeoutConfig {
209 connect_timeout: Duration::from_secs(config.request_timeout_secs),
210 read_timeout: Duration::from_secs(config.request_timeout_secs),
211 write_timeout: Duration::from_secs(30),
212 };
213 endpoint_config.security = SecurityConfig {
214 enable_tls: true,
215 min_tls_version: "1.2".to_string(),
216 cipher_suites: vec![
217 "TLS_AES_256_GCM_SHA384".to_string(),
218 "TLS_CHACHA20_POLY1305_SHA256".to_string(),
219 "TLS_AES_128_GCM_SHA256".to_string(),
220 ],
221 cert_validation: crate::server::core::common_config::CertificateValidation::Full,
222 verify_certificates: true,
223 };
224 endpoint_config
225 .headers
226 .insert("User-Agent".to_string(), config.user_agent.clone());
227
228 let http_client = crate::server::core::common_http::HttpClient::new(endpoint_config)?;
229
230 Ok(Self {
231 config,
232 session_manager,
233 http_client,
234 rp_configs: HashMap::new(),
235 active_logouts: HashMap::new(),
236 })
237 }
238
239 pub fn register_rp_config(&mut self, rp_config: RpBackChannelConfig) {
241 self.rp_configs
242 .insert(rp_config.client_id.clone(), rp_config);
243 }
244
245 pub async fn process_backchannel_logout(
247 &mut self,
248 request: BackChannelLogoutRequest,
249 ) -> Result<BackChannelLogoutResponse> {
250 if !self.config.enabled {
251 return Err(AuthError::validation("Back-channel logout is not enabled"));
252 }
253
254 let user_sessions = self.session_manager.get_sessions_for_subject(&request.sub);
256
257 let mut rps_to_notify = Vec::new();
259 for session in user_sessions {
260 if session.session_id == request.session_id {
262 continue;
263 }
264
265 if let Some(rp_config) = self.rp_configs.get(&session.client_id) {
267 if let Some(ref initiating_client) = request.initiating_client_id
269 && &session.client_id == initiating_client
270 {
271 continue;
272 }
273
274 rps_to_notify.push((session.clone(), rp_config.clone()));
275 }
276 }
277
278 let logout_token_jti = Uuid::new_v4().to_string();
280 let logout_token = self
281 .generate_logout_token(&request, &logout_token_jti)
282 .map_err(|e| {
283 AuthError::validation(format!("Failed to generate logout token: {}", e))
284 })?;
285
286 let mut successful_notifications = Vec::new();
288 let mut failed_notifications = Vec::new();
289
290 let chunk_size = self.config.max_concurrent_notifications;
292 for chunk in rps_to_notify.chunks(chunk_size) {
293 let mut tasks = Vec::new();
294
295 for (session, rp_config) in chunk {
296 let logout_token_clone = logout_token.clone();
297 let rp_config_clone = rp_config.clone();
298 let session_clone = session.clone();
299 let client_clone = self.http_client.clone();
300 let config_clone = self.config.clone();
301
302 let task = tokio::spawn(async move {
303 Self::send_backchannel_notification(
304 client_clone,
305 config_clone,
306 session_clone,
307 rp_config_clone,
308 logout_token_clone,
309 )
310 .await
311 });
312
313 tasks.push(task);
314 }
315
316 for task in tasks {
318 match task.await {
319 Ok(Ok(notification_result)) => {
320 successful_notifications.push(notification_result);
321 }
322 Ok(Err(failed_notification)) => {
323 failed_notifications.push(failed_notification);
324 }
325 Err(e) => {
326 failed_notifications.push(FailedNotification {
327 client_id: "unknown".to_string(),
328 backchannel_logout_uri: "unknown".to_string(),
329 error: format!("Task execution failed: {}", e),
330 status_code: None,
331 retry_attempts: 0,
332 });
333 }
334 }
335 }
336 }
337
338 self.active_logouts
340 .insert(logout_token_jti.clone(), SystemTime::now());
341
342 Ok(BackChannelLogoutResponse {
343 success: failed_notifications.is_empty(),
344 notified_rps: successful_notifications.len(),
345 successful_notifications,
346 failed_notifications,
347 logout_token_jti,
348 })
349 }
350
351 fn generate_logout_token(
359 &self,
360 request: &BackChannelLogoutRequest,
361 jti: &str,
362 ) -> Result<String> {
363 use base64::Engine as _;
364 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
365
366 let now = chrono::Utc::now().timestamp();
368
369 let mut events = serde_json::json!({
371 "http://schemas.openid.net/secevent/oauth/event-type/logout": {}
372 });
373
374 if let Some(ref additional_events) = request.additional_events {
376 for (event_type, event_data) in additional_events {
377 let validated_event = serde_from_value::<serde_json::Value>(event_data.clone())?;
379 events[event_type] = validated_event;
380 }
381 }
382
383 let claims = serde_json::json!({
384 "iss": request.iss,
385 "sub": request.sub,
386 "aud": request.initiating_client_id.as_ref().unwrap_or(&"default_client".to_string()),
387 "iat": now,
388 "jti": jti,
389 "events": events,
390 });
392
393 let header = serde_json::json!({
395 "alg": "RS256",
396 "typ": "logout+jwt",
397 });
398
399 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
401 let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
402 let signing_input = format!("{}.{}", header_b64, claims_b64);
403
404 let signature = self.generate_logout_token_signature(&signing_input)?;
406 let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
407
408 Ok(format!("{}.{}.{}", header_b64, claims_b64, signature_b64))
409 }
410
411 fn generate_logout_token_signature(&self, signing_input: &str) -> Result<Vec<u8>> {
413 use sha2::{Digest, Sha256};
414
415 let mut hasher = Sha256::new();
416 hasher.update(signing_input.as_bytes());
417 hasher.update(b"logout_token_signature_salt");
418
419 Ok(hasher.finalize().to_vec())
422 }
423
424 async fn send_backchannel_notification(
426 client: crate::server::core::common_http::HttpClient,
427 config: BackChannelLogoutConfig,
428 session: OidcSession,
429 rp_config: RpBackChannelConfig,
430 logout_token: String,
431 ) -> Result<NotificationResult, FailedNotification> {
432 use std::collections::HashMap;
433
434 let client_id = session.client_id.clone();
435 let backchannel_logout_uri = rp_config.backchannel_logout_uri.clone();
436
437 let mut form_data = HashMap::new();
439 form_data.insert("logout_token".to_string(), logout_token);
440
441 let mut retry_count = 0;
442 let max_retries = config.max_retry_attempts;
443 let start_time = std::time::Instant::now();
444
445 loop {
446 let response = client.post_form(&backchannel_logout_uri, &form_data).await;
448
449 match response {
450 Ok(resp) => {
451 let status_code = resp.status().as_u16();
452 let response_time = start_time.elapsed().as_millis() as u64;
453
454 if resp.status().is_success() {
455 return Ok(NotificationResult {
456 client_id,
457 backchannel_logout_uri,
458 success: true,
459 status_code: Some(status_code),
460 retry_attempts: retry_count,
461 response_time_ms: response_time,
462 });
463 } else if retry_count < max_retries && Self::is_retryable_status(status_code) {
464 retry_count += 1;
466 let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
467 tokio::time::sleep(delay).await;
468 continue;
469 } else {
470 let body = resp.text().await.unwrap_or_default();
471 return Err(FailedNotification {
472 client_id,
473 backchannel_logout_uri,
474 error: format!("HTTP {}: {}", status_code, body),
475 status_code: Some(status_code),
476 retry_attempts: retry_count,
477 });
478 }
479 }
480 Err(e) => {
481 if retry_count < max_retries {
482 retry_count += 1;
483 let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
484 tokio::time::sleep(delay).await;
485 continue;
486 } else {
487 return Err(FailedNotification {
488 client_id,
489 backchannel_logout_uri,
490 error: format!("Request failed: {}", e),
491 status_code: None,
492 retry_attempts: retry_count,
493 });
494 }
495 }
496 }
497 }
498 }
499
500 fn is_retryable_status(status_code: u16) -> bool {
502 match status_code {
503 429 => true,
505 408 => true,
507 500..=599 => true,
509 _ => false,
510 }
511 }
512
513 pub fn cleanup_expired_logouts(&mut self) -> usize {
515 let now = SystemTime::now();
516 let initial_count = self.active_logouts.len();
517
518 self.active_logouts.retain(|_, timestamp| {
519 now.duration_since(*timestamp)
520 .map(|d| d.as_secs() < 3600) .unwrap_or(false)
522 });
523
524 initial_count - self.active_logouts.len()
525 }
526
527 pub fn get_discovery_metadata(&self) -> HashMap<String, serde_json::Value> {
529 let mut metadata = HashMap::new();
530
531 if self.config.enabled {
532 metadata.insert(
533 "backchannel_logout_supported".to_string(),
534 serde_json::Value::Bool(true),
535 );
536
537 metadata.insert(
538 "backchannel_logout_session_supported".to_string(),
539 serde_json::Value::Bool(self.config.include_session_claims),
540 );
541 }
542
543 metadata
544 }
545}
546
547fn serde_from_value<T>(value: serde_json::Value) -> Result<T>
549where
550 T: serde::de::DeserializeOwned,
551{
552 serde_json::from_value(value)
553 .map_err(|e| AuthError::internal(format!("JSON deserialization error: {}", e)))
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use crate::server::oidc::oidc_session_management::SessionManagementConfig;
560
561 fn create_test_manager() -> Result<BackChannelLogoutManager> {
562 let config = BackChannelLogoutConfig::default();
563 let session_manager = SessionManager::new(SessionManagementConfig::default());
564 BackChannelLogoutManager::new(config, session_manager)
565 }
566
567 #[test]
568 fn test_retryable_status_codes() {
569 assert!(BackChannelLogoutManager::is_retryable_status(500));
571 assert!(BackChannelLogoutManager::is_retryable_status(502));
572 assert!(BackChannelLogoutManager::is_retryable_status(503));
573
574 assert!(BackChannelLogoutManager::is_retryable_status(429));
576
577 assert!(!BackChannelLogoutManager::is_retryable_status(400));
579 assert!(!BackChannelLogoutManager::is_retryable_status(401));
580 assert!(!BackChannelLogoutManager::is_retryable_status(404));
581
582 assert!(!BackChannelLogoutManager::is_retryable_status(200));
584 assert!(!BackChannelLogoutManager::is_retryable_status(204));
585 }
586
587 #[test]
588 fn test_logout_token_generation() -> Result<()> {
589 let manager = create_test_manager()?;
590
591 let request = BackChannelLogoutRequest {
592 session_id: "session123".to_string(),
593 sub: "user123".to_string(),
594 sid: Some("sid123".to_string()),
595 iss: "https://op.example.com".to_string(),
596 initiating_client_id: None,
597 additional_events: None,
598 };
599
600 let token = manager.generate_logout_token(&request, "jti123")?;
601
602 assert!(!token.is_empty());
603 assert_eq!(token.split('.').count(), 3);
605
606 Ok(())
607 }
608
609 #[test]
610 fn test_logout_token_with_additional_events() -> Result<()> {
611 let manager = create_test_manager()?;
612
613 let mut additional_events = HashMap::new();
615 additional_events.insert(
616 "http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
617 .to_string(),
618 serde_json::json!({
619 "reason": "password_change",
620 "timestamp": "2025-08-07T12:00:00Z"
621 }),
622 );
623 additional_events.insert(
624 "custom-event-type".to_string(),
625 serde_json::json!({
626 "custom_field": "custom_value"
627 }),
628 );
629
630 let request = BackChannelLogoutRequest {
631 session_id: "session123".to_string(),
632 sub: "user123".to_string(),
633 sid: Some("sid123".to_string()),
634 iss: "https://op.example.com".to_string(),
635 initiating_client_id: Some("client_456".to_string()),
636 additional_events: Some(additional_events),
637 };
638
639 let token = manager.generate_logout_token(&request, "jti456")?;
640
641 assert!(!token.is_empty());
642 assert_eq!(token.split('.').count(), 3);
644
645 use base64::Engine as _;
647 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
648
649 let parts: Vec<&str> = token.split('.').collect();
650 assert_eq!(parts.len(), 3);
651
652 let claims_json = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
654 let claims: serde_json::Value = serde_json::from_str(&claims_json).unwrap();
655
656 let events = &claims["events"];
658 assert!(events["http://schemas.openid.net/secevent/oauth/event-type/logout"].is_object());
659 assert!(events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"].is_object());
660 assert!(events["custom-event-type"].is_object());
661
662 assert_eq!(
664 events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"]
665 ["reason"],
666 "password_change"
667 );
668 assert_eq!(events["custom-event-type"]["custom_field"], "custom_value");
669
670 Ok(())
671 }
672
673 #[test]
674 fn test_discovery_metadata() -> Result<()> {
675 let manager = create_test_manager()?;
676 let metadata = manager.get_discovery_metadata();
677
678 assert_eq!(
679 metadata.get("backchannel_logout_supported"),
680 Some(&serde_json::Value::Bool(true))
681 );
682 assert_eq!(
683 metadata.get("backchannel_logout_session_supported"),
684 Some(&serde_json::Value::Bool(true))
685 );
686
687 Ok(())
688 }
689}
690
691