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 pub signing_key: Option<Vec<u8>>,
114 pub rsa_private_key_pem: Option<String>,
118}
119
120impl Default for BackChannelLogoutConfig {
121 fn default() -> Self {
122 Self {
123 enabled: true,
124 base_url: None,
125 request_timeout_secs: 30,
126 max_retry_attempts: 3,
127 retry_delay_ms: 1000, max_concurrent_notifications: 10,
129 logout_token_exp_secs: 120, include_session_claims: true,
131 user_agent: "AuthFramework-OIDC/1.0".to_string(),
132 enable_http_logging: false,
133 signing_key: None,
134 rsa_private_key_pem: None,
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
141pub struct RpBackChannelConfig {
142 pub client_id: String,
144 pub backchannel_logout_uri: String,
146 pub backchannel_logout_session_required: bool,
148 pub custom_timeout_secs: Option<u64>,
150 pub custom_max_retries: Option<u32>,
152 pub authentication_method: Option<String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct LogoutTokenClaims {
159 pub iss: String,
161 pub sub: Option<String>,
163 pub aud: Vec<String>,
165 pub iat: u64,
167 pub jti: String,
169 pub events: LogoutEvents,
171 pub sid: Option<String>,
173 pub exp: u64,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct LogoutEvents {
180 #[serde(
182 rename = "http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
183 )]
184 pub backchannel_logout: Option<serde_json::Value>,
185
186 #[serde(rename = "http://schemas.openid.net/secevent/oauth/event-type/token-revocation")]
188 pub token_revocation: Option<serde_json::Value>,
189}
190
191#[derive(Debug)]
193pub struct BackChannelLogoutManager {
194 config: BackChannelLogoutConfig,
196 session_manager: SessionManager,
198 http_client: crate::server::core::common_http::HttpClient,
200 rp_configs: HashMap<String, RpBackChannelConfig>,
202 active_logouts: HashMap<String, SystemTime>,
204 signing_key: Vec<u8>,
206 rsa_signing_key: Option<ring::signature::RsaKeyPair>,
208}
209
210impl BackChannelLogoutManager {
211 pub fn new(config: BackChannelLogoutConfig, session_manager: SessionManager) -> Result<Self> {
213 use crate::server::core::common_config::{EndpointConfig, SecurityConfig, TimeoutConfig};
214
215 let mut endpoint_config = EndpointConfig::new(
217 config
218 .base_url
219 .as_ref()
220 .unwrap_or(&"http://localhost:8080".to_string()),
221 );
222 endpoint_config.timeout = TimeoutConfig {
223 connect_timeout: Duration::from_secs(config.request_timeout_secs),
224 read_timeout: Duration::from_secs(config.request_timeout_secs),
225 write_timeout: Duration::from_secs(30),
226 };
227 endpoint_config.security = SecurityConfig {
228 enable_tls: true,
229 min_tls_version: "1.2".to_string(),
230 cipher_suites: vec![
231 "TLS_AES_256_GCM_SHA384".to_string(),
232 "TLS_CHACHA20_POLY1305_SHA256".to_string(),
233 "TLS_AES_128_GCM_SHA256".to_string(),
234 ],
235 cert_validation: crate::server::core::common_config::CertificateValidation::Full,
236 verify_certificates: true,
237 accept_invalid_certs: false,
238 };
239 endpoint_config
240 .headers
241 .insert("User-Agent".to_string(), config.user_agent.clone());
242
243 let http_client = crate::server::core::common_http::HttpClient::new(endpoint_config)?;
244
245 let signing_key = config.signing_key.clone().unwrap_or_else(|| {
247 use ring::rand::{SecureRandom, SystemRandom};
248 let rng = SystemRandom::new();
249 let mut key = vec![0u8; 32];
250 rng.fill(&mut key)
251 .expect("AuthFramework fatal: system CSPRNG unavailable");
252 key
253 });
254
255 let rsa_signing_key = config.rsa_private_key_pem.as_ref().and_then(|pem| {
257 let der = pem
259 .lines()
260 .filter(|l| !l.starts_with("-----"))
261 .collect::<String>();
262 let der_bytes = base64::Engine::decode(
263 &base64::engine::general_purpose::STANDARD,
264 &der,
265 ).ok()?;
266 ring::signature::RsaKeyPair::from_pkcs8(&der_bytes)
267 .or_else(|_| ring::signature::RsaKeyPair::from_der(&der_bytes))
268 .ok()
269 });
270
271 Ok(Self {
272 config,
273 session_manager,
274 http_client,
275 rp_configs: HashMap::new(),
276 active_logouts: HashMap::new(),
277 signing_key,
278 rsa_signing_key,
279 })
280 }
281
282 pub fn register_rp_config(&mut self, rp_config: RpBackChannelConfig) {
284 self.rp_configs
285 .insert(rp_config.client_id.clone(), rp_config);
286 }
287
288 pub async fn process_backchannel_logout(
290 &mut self,
291 request: BackChannelLogoutRequest,
292 ) -> Result<BackChannelLogoutResponse> {
293 if !self.config.enabled {
294 return Err(AuthError::validation("Back-channel logout is not enabled"));
295 }
296
297 let user_sessions = self.session_manager.get_sessions_for_subject(&request.sub);
299
300 let mut rps_to_notify = Vec::new();
302 for session in user_sessions {
303 if session.session_id == request.session_id {
305 continue;
306 }
307
308 if let Some(rp_config) = self.rp_configs.get(&session.client_id) {
310 if let Some(ref initiating_client) = request.initiating_client_id
312 && &session.client_id == initiating_client
313 {
314 continue;
315 }
316
317 rps_to_notify.push((session.clone(), rp_config.clone()));
318 }
319 }
320
321 let logout_token_jti = Uuid::new_v4().to_string();
323 let logout_token = self
324 .generate_logout_token(&request, &logout_token_jti)
325 .map_err(|e| {
326 AuthError::validation(format!("Failed to generate logout token: {}", e))
327 })?;
328
329 let mut successful_notifications = Vec::new();
331 let mut failed_notifications = Vec::new();
332
333 let chunk_size = self.config.max_concurrent_notifications;
335 for chunk in rps_to_notify.chunks(chunk_size) {
336 let mut tasks = Vec::new();
337
338 for (session, rp_config) in chunk {
339 let logout_token_clone = logout_token.clone();
340 let rp_config_clone = rp_config.clone();
341 let session_clone = session.clone();
342 let client_clone = self.http_client.clone();
343 let config_clone = self.config.clone();
344
345 let task = tokio::spawn(async move {
346 Self::send_backchannel_notification(
347 client_clone,
348 config_clone,
349 session_clone,
350 rp_config_clone,
351 logout_token_clone,
352 )
353 .await
354 });
355
356 tasks.push(task);
357 }
358
359 for task in tasks {
361 match task.await {
362 Ok(Ok(notification_result)) => {
363 successful_notifications.push(notification_result);
364 }
365 Ok(Err(failed_notification)) => {
366 failed_notifications.push(failed_notification);
367 }
368 Err(e) => {
369 failed_notifications.push(FailedNotification {
370 client_id: "unknown".to_string(),
371 backchannel_logout_uri: "unknown".to_string(),
372 error: format!("Task execution failed: {}", e),
373 status_code: None,
374 retry_attempts: 0,
375 });
376 }
377 }
378 }
379 }
380
381 self.active_logouts
383 .insert(logout_token_jti.clone(), SystemTime::now());
384
385 Ok(BackChannelLogoutResponse {
386 success: failed_notifications.is_empty(),
387 notified_rps: successful_notifications.len(),
388 successful_notifications,
389 failed_notifications,
390 logout_token_jti,
391 })
392 }
393
394 fn generate_logout_token(
402 &self,
403 request: &BackChannelLogoutRequest,
404 jti: &str,
405 ) -> Result<String> {
406 use base64::Engine as _;
407 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
408
409 let now = chrono::Utc::now().timestamp();
411
412 let mut events = serde_json::json!({
414 "http://schemas.openid.net/secevent/oauth/event-type/logout": {}
415 });
416
417 if let Some(ref additional_events) = request.additional_events {
419 for (event_type, event_data) in additional_events {
420 let validated_event = serde_from_value::<serde_json::Value>(event_data.clone())?;
422 events[event_type] = validated_event;
423 }
424 }
425
426 let claims = serde_json::json!({
427 "iss": request.iss,
428 "sub": request.sub,
429 "aud": request.initiating_client_id.as_ref().unwrap_or(&"default_client".to_string()),
430 "iat": now,
431 "jti": jti,
432 "events": events,
433 });
435
436 let alg = if self.rsa_signing_key.is_some() {
438 "RS256"
439 } else {
440 "HS256"
441 };
442
443 let header = serde_json::json!({
445 "alg": alg,
446 "typ": "logout+jwt",
447 });
448
449 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
451 let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
452 let signing_input = format!("{}.{}", header_b64, claims_b64);
453
454 let signature = self.generate_logout_token_signature(&signing_input)?;
455 let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
456
457 Ok(format!("{}.{}.{}", header_b64, claims_b64, signature_b64))
458 }
459
460 fn generate_logout_token_signature(&self, signing_input: &str) -> Result<Vec<u8>> {
462 if let Some(ref rsa_key) = self.rsa_signing_key {
463 let rng = ring::rand::SystemRandom::new();
465 let mut sig = vec![0u8; rsa_key.public().modulus_len()];
466 rsa_key
467 .sign(
468 &ring::signature::RSA_PKCS1_SHA256,
469 &rng,
470 signing_input.as_bytes(),
471 &mut sig,
472 )
473 .map_err(|e| AuthError::internal(format!("RSA signing error: {e}")))?;
474 Ok(sig)
475 } else {
476 use hmac::{Hmac, Mac};
478 use sha2::Sha256;
479 type HmacSha256 = Hmac<Sha256>;
480 let mut mac = HmacSha256::new_from_slice(&self.signing_key)
481 .map_err(|e| AuthError::internal(format!("HMAC key error: {}", e)))?;
482 mac.update(signing_input.as_bytes());
483 Ok(mac.finalize().into_bytes().to_vec())
484 }
485 }
486
487 async fn send_backchannel_notification(
489 client: crate::server::core::common_http::HttpClient,
490 config: BackChannelLogoutConfig,
491 session: OidcSession,
492 rp_config: RpBackChannelConfig,
493 logout_token: String,
494 ) -> Result<NotificationResult, FailedNotification> {
495 use std::collections::HashMap;
496
497 let client_id = session.client_id.clone();
498 let backchannel_logout_uri = rp_config.backchannel_logout_uri.clone();
499
500 let mut form_data = HashMap::new();
502 form_data.insert("logout_token".to_string(), logout_token);
503
504 let mut retry_count = 0;
505 let max_retries = config.max_retry_attempts;
506 let start_time = std::time::Instant::now();
507
508 loop {
509 let response = client.post_form(&backchannel_logout_uri, &form_data).await;
511
512 match response {
513 Ok(resp) => {
514 let status_code = resp.status().as_u16();
515 let response_time = start_time.elapsed().as_millis() as u64;
516
517 if resp.status().is_success() {
518 return Ok(NotificationResult {
519 client_id,
520 backchannel_logout_uri,
521 success: true,
522 status_code: Some(status_code),
523 retry_attempts: retry_count,
524 response_time_ms: response_time,
525 });
526 } else if retry_count < max_retries && Self::is_retryable_status(status_code) {
527 retry_count += 1;
529 let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
530 tokio::time::sleep(delay).await;
531 continue;
532 } else {
533 let body = resp.text().await.unwrap_or_default();
534 return Err(FailedNotification {
535 client_id,
536 backchannel_logout_uri,
537 error: format!("HTTP {}: {}", status_code, body),
538 status_code: Some(status_code),
539 retry_attempts: retry_count,
540 });
541 }
542 }
543 Err(e) => {
544 if retry_count < max_retries {
545 retry_count += 1;
546 let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
547 tokio::time::sleep(delay).await;
548 continue;
549 } else {
550 return Err(FailedNotification {
551 client_id,
552 backchannel_logout_uri,
553 error: format!("Request failed: {}", e),
554 status_code: None,
555 retry_attempts: retry_count,
556 });
557 }
558 }
559 }
560 }
561 }
562
563 fn is_retryable_status(status_code: u16) -> bool {
565 match status_code {
566 429 => true,
568 408 => true,
570 500..=599 => true,
572 _ => false,
573 }
574 }
575
576 pub fn cleanup_expired_logouts(&mut self) -> usize {
578 let now = SystemTime::now();
579 let initial_count = self.active_logouts.len();
580
581 self.active_logouts.retain(|_, timestamp| {
582 now.duration_since(*timestamp)
583 .map(|d| d.as_secs() < 3600) .unwrap_or(false)
585 });
586
587 initial_count - self.active_logouts.len()
588 }
589
590 pub fn get_discovery_metadata(&self) -> HashMap<String, serde_json::Value> {
592 let mut metadata = HashMap::new();
593
594 if self.config.enabled {
595 metadata.insert(
596 "backchannel_logout_supported".to_string(),
597 serde_json::Value::Bool(true),
598 );
599
600 metadata.insert(
601 "backchannel_logout_session_supported".to_string(),
602 serde_json::Value::Bool(self.config.include_session_claims),
603 );
604 }
605
606 metadata
607 }
608}
609
610fn serde_from_value<T>(value: serde_json::Value) -> Result<T>
612where
613 T: serde::de::DeserializeOwned,
614{
615 serde_json::from_value(value)
616 .map_err(|e| AuthError::internal(format!("JSON deserialization error: {}", e)))
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::server::oidc::oidc_session_management::SessionManagementConfig;
623
624 fn create_test_manager() -> Result<BackChannelLogoutManager> {
625 let config = BackChannelLogoutConfig::default();
626 let session_manager = SessionManager::new(SessionManagementConfig::default());
627 BackChannelLogoutManager::new(config, session_manager)
628 }
629
630 #[test]
631 fn test_retryable_status_codes() {
632 assert!(BackChannelLogoutManager::is_retryable_status(500));
634 assert!(BackChannelLogoutManager::is_retryable_status(502));
635 assert!(BackChannelLogoutManager::is_retryable_status(503));
636
637 assert!(BackChannelLogoutManager::is_retryable_status(429));
639
640 assert!(!BackChannelLogoutManager::is_retryable_status(400));
642 assert!(!BackChannelLogoutManager::is_retryable_status(401));
643 assert!(!BackChannelLogoutManager::is_retryable_status(404));
644
645 assert!(!BackChannelLogoutManager::is_retryable_status(200));
647 assert!(!BackChannelLogoutManager::is_retryable_status(204));
648 }
649
650 #[test]
651 fn test_logout_token_generation() -> Result<()> {
652 let manager = create_test_manager()?;
653
654 let request = BackChannelLogoutRequest {
655 session_id: "session123".to_string(),
656 sub: "user123".to_string(),
657 sid: Some("sid123".to_string()),
658 iss: "https://op.example.com".to_string(),
659 initiating_client_id: None,
660 additional_events: None,
661 };
662
663 let token = manager.generate_logout_token(&request, "jti123")?;
664
665 assert!(!token.is_empty());
666 assert_eq!(token.split('.').count(), 3);
668
669 Ok(())
670 }
671
672 #[test]
673 fn test_logout_token_with_additional_events() -> Result<()> {
674 let manager = create_test_manager()?;
675
676 let mut additional_events = HashMap::new();
678 additional_events.insert(
679 "http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
680 .to_string(),
681 serde_json::json!({
682 "reason": "password_change",
683 "timestamp": "2025-08-07T12:00:00Z"
684 }),
685 );
686 additional_events.insert(
687 "custom-event-type".to_string(),
688 serde_json::json!({
689 "custom_field": "custom_value"
690 }),
691 );
692
693 let request = BackChannelLogoutRequest {
694 session_id: "session123".to_string(),
695 sub: "user123".to_string(),
696 sid: Some("sid123".to_string()),
697 iss: "https://op.example.com".to_string(),
698 initiating_client_id: Some("client_456".to_string()),
699 additional_events: Some(additional_events),
700 };
701
702 let token = manager.generate_logout_token(&request, "jti456")?;
703
704 assert!(!token.is_empty());
705 assert_eq!(token.split('.').count(), 3);
707
708 use base64::Engine as _;
710 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
711
712 let parts: Vec<&str> = token.split('.').collect();
713 assert_eq!(parts.len(), 3);
714
715 let claims_json = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
717 let claims: serde_json::Value = serde_json::from_str(&claims_json).unwrap();
718
719 let events = &claims["events"];
721 assert!(events["http://schemas.openid.net/secevent/oauth/event-type/logout"].is_object());
722 assert!(events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"].is_object());
723 assert!(events["custom-event-type"].is_object());
724
725 assert_eq!(
727 events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"]
728 ["reason"],
729 "password_change"
730 );
731 assert_eq!(events["custom-event-type"]["custom_field"], "custom_value");
732
733 Ok(())
734 }
735
736 #[test]
737 fn test_discovery_metadata() -> Result<()> {
738 let manager = create_test_manager()?;
739 let metadata = manager.get_discovery_metadata();
740
741 assert_eq!(
742 metadata.get("backchannel_logout_supported"),
743 Some(&serde_json::Value::Bool(true))
744 );
745 assert_eq!(
746 metadata.get("backchannel_logout_session_supported"),
747 Some(&serde_json::Value::Bool(true))
748 );
749
750 Ok(())
751 }
752}