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 uuid::Uuid;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FrontChannelLogoutRequest {
25 pub session_id: String,
27 pub sub: String,
29 pub sid: Option<String>,
31 pub iss: String,
33 pub initiating_client_id: Option<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct FrontChannelLogoutResponse {
40 pub success: bool,
42 pub notified_rps: usize,
44 pub notified_clients: Vec<String>,
46 pub failed_notifications: Vec<FailedNotification>,
48 pub logout_page_html: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct FailedNotification {
55 pub client_id: String,
57 pub frontchannel_logout_uri: String,
59 pub error: String,
61}
62
63#[derive(Debug, Clone)]
65pub struct FrontChannelLogoutConfig {
66 pub enabled: bool,
68 pub iframe_timeout_ms: u64,
70 pub max_concurrent_notifications: usize,
72 pub include_session_state: bool,
74 pub iframe_width: u32,
76 pub iframe_height: u32,
77 pub enable_debug_logging: bool,
79}
80
81impl Default for FrontChannelLogoutConfig {
82 fn default() -> Self {
83 Self {
84 enabled: true,
85 iframe_timeout_ms: 5000, max_concurrent_notifications: 10,
87 include_session_state: true,
88 iframe_width: 0, iframe_height: 0, enable_debug_logging: false,
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct RpFrontChannelConfig {
98 pub client_id: String,
100 pub frontchannel_logout_uri: String,
102 pub frontchannel_logout_session_required: bool,
104 pub custom_timeout_ms: Option<u64>,
106}
107
108#[derive(Debug, Clone)]
110pub struct FrontChannelLogoutManager {
111 config: FrontChannelLogoutConfig,
113 session_manager: SessionManager,
115 rp_configs: HashMap<String, RpFrontChannelConfig>,
117 active_logouts: HashMap<String, SystemTime>,
119}
120
121impl FrontChannelLogoutManager {
122 pub fn new(config: FrontChannelLogoutConfig, session_manager: SessionManager) -> Self {
124 Self {
125 config,
126 session_manager,
127 rp_configs: HashMap::new(),
128 active_logouts: HashMap::new(),
129 }
130 }
131
132 pub fn register_rp_config(&mut self, rp_config: RpFrontChannelConfig) {
134 self.rp_configs
135 .insert(rp_config.client_id.clone(), rp_config);
136 }
137
138 pub async fn process_frontchannel_logout(
140 &mut self,
141 request: FrontChannelLogoutRequest,
142 ) -> Result<FrontChannelLogoutResponse> {
143 if !self.config.enabled {
144 return Err(AuthError::validation("Front-channel logout is not enabled"));
145 }
146
147 let user_sessions = self.session_manager.get_sessions_for_subject(&request.sub);
149
150 let mut rps_to_notify = Vec::new();
152 let mut notified_clients = Vec::new();
153 let mut failed_notifications = Vec::new();
154
155 for session in user_sessions {
156 if session.session_id == request.session_id {
158 continue;
159 }
160
161 if let Some(rp_config) = self.rp_configs.get(&session.client_id) {
163 if let Some(ref initiating_client) = request.initiating_client_id
165 && &session.client_id == initiating_client
166 {
167 continue;
168 }
169
170 rps_to_notify.push((session.clone(), rp_config.clone()));
171 }
172 }
173
174 let mut iframe_urls = Vec::new();
176 for (session, rp_config) in &rps_to_notify {
177 match self.build_frontchannel_logout_url(session, rp_config, &request) {
178 Ok(url) => {
179 iframe_urls.push((
180 rp_config.client_id.clone(),
181 url,
182 rp_config.custom_timeout_ms,
183 ));
184 notified_clients.push(rp_config.client_id.clone());
185 }
186 Err(e) => {
187 failed_notifications.push(FailedNotification {
188 client_id: rp_config.client_id.clone(),
189 frontchannel_logout_uri: rp_config.frontchannel_logout_uri.clone(),
190 error: e.to_string(),
191 });
192 }
193 }
194 }
195
196 let logout_page_html = self.generate_frontchannel_logout_html(&iframe_urls);
198
199 let logout_id = Uuid::new_v4().to_string();
201 self.active_logouts.insert(logout_id, SystemTime::now());
202
203 Ok(FrontChannelLogoutResponse {
204 success: failed_notifications.is_empty(),
205 notified_rps: notified_clients.len(),
206 notified_clients,
207 failed_notifications,
208 logout_page_html,
209 })
210 }
211
212 fn build_frontchannel_logout_url(
214 &self,
215 session: &OidcSession,
216 rp_config: &RpFrontChannelConfig,
217 logout_request: &FrontChannelLogoutRequest,
218 ) -> Result<String> {
219 let mut url = rp_config.frontchannel_logout_uri.clone();
220 let mut params = Vec::new();
221
222 params.push(format!("iss={}", urlencoding::encode(&logout_request.iss)));
224
225 if self.config.include_session_state || rp_config.frontchannel_logout_session_required {
227 if let Some(sid) = &logout_request.sid {
228 params.push(format!("sid={}", urlencoding::encode(sid)));
229 } else {
230 let sid = format!("sess_{}", &session.session_id[..8]);
232 params.push(format!("sid={}", urlencoding::encode(&sid)));
233 }
234 }
235
236 let separator = if url.contains('?') { "&" } else { "?" };
238 if !params.is_empty() {
239 url = format!("{}{}{}", url, separator, params.join("&"));
240 }
241
242 if !self.is_valid_frontchannel_url(&url) {
244 return Err(AuthError::validation("Invalid front-channel logout URL"));
245 }
246
247 Ok(url)
248 }
249
250 fn is_valid_frontchannel_url(&self, url: &str) -> bool {
252 if url.is_empty() {
254 return false;
255 }
256
257 if !url.starts_with("https://")
259 && !url.starts_with("http://localhost")
260 && !url.starts_with("http://127.0.0.1")
261 {
262 return false;
263 }
264
265 if url.contains('\n') || url.contains('\r') || url.contains('<') || url.contains('>') {
267 return false;
268 }
269
270 true
271 }
272
273 fn generate_frontchannel_logout_html(
275 &self,
276 iframe_urls: &[(String, String, Option<u64>)],
277 ) -> String {
278 fn html_escape(s: &str) -> String {
280 s.replace('&', "&")
281 .replace('"', """)
282 .replace('\'', "'")
283 .replace('<', "<")
284 .replace('>', ">")
285 }
286
287 let mut iframes_html = String::new();
288 let mut timeout_scripts = String::new();
289
290 for (i, (client_id, url, custom_timeout)) in iframe_urls.iter().enumerate() {
291 let timeout = custom_timeout.unwrap_or(self.config.iframe_timeout_ms);
292 let escaped_url = html_escape(url);
293 let escaped_client_id = html_escape(client_id);
294
295 iframes_html.push_str(&format!(
296 r#" <iframe id="fc_logout_{}" src="{}" width="{}" height="{}" style="display:none; visibility:hidden;"
297 onload="handleIframeLoad('{}')"
298 onerror="handleIframeError('{}')"></iframe>
299"#,
300 i, escaped_url, self.config.iframe_width, self.config.iframe_height, escaped_client_id, escaped_client_id
301 ));
302
303 timeout_scripts.push_str(&format!(
305 r#" setTimeout(function() {{
306 handleIframeTimeout('{}', {});
307 }}, {});
308"#,
309 client_id, i, timeout
310 ));
311 }
312
313 let debug_logging = if self.config.enable_debug_logging {
314 "const DEBUG_LOGGING = true;"
315 } else {
316 "const DEBUG_LOGGING = false;"
317 };
318
319 format!(
320 r#"<!DOCTYPE html>
321<html>
322<head>
323 <title>Logging Out...</title>
324 <style>
325 body {{
326 font-family: Arial, sans-serif;
327 margin: 40px;
328 text-align: center;
329 background-color: #f8f9fa;
330 }}
331 .logout-container {{
332 max-width: 400px;
333 margin: 50px auto;
334 background: white;
335 padding: 40px;
336 border-radius: 8px;
337 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
338 }}
339 .spinner {{
340 border: 4px solid #f3f3f3;
341 border-top: 4px solid #007bff;
342 border-radius: 50%;
343 width: 50px;
344 height: 50px;
345 animation: spin 1s linear infinite;
346 margin: 20px auto;
347 }}
348 @keyframes spin {{
349 0% {{ transform: rotate(0deg); }}
350 100% {{ transform: rotate(360deg); }}
351 }}
352 .status {{ margin-top: 20px; color: #666; }}
353 .complete {{ color: #28a745; font-weight: bold; }}
354 .error {{ color: #dc3545; }}
355 </style>
356</head>
357<body>
358 <div class="logout-container">
359 <h1>🔐 Logging Out</h1>
360 <div class="spinner"></div>
361 <div id="status" class="status">
362 Notifying applications of logout...
363 </div>
364 <div id="progress" class="status">
365 <span id="completed">0</span> of <span id="total">{}</span> notifications sent
366 </div>
367 </div>
368
369 <!-- Hidden iframes for front-channel logout notifications -->
370{}
371
372 <script>
373 {}
374
375 let completedNotifications = 0;
376 let totalNotifications = {};
377 let errors = [];
378
379 function log(message) {{
380 if (DEBUG_LOGGING) {{
381 console.log('[FrontChannel Logout] ' + message);
382 }}
383 }}
384
385 function handleIframeLoad(clientId) {{
386 completedNotifications++;
387 log('Logout notification sent to: ' + clientId);
388 updateProgress();
389 }}
390
391 function handleIframeError(clientId) {{
392 completedNotifications++;
393 errors.push(clientId);
394 log('Logout notification failed for: ' + clientId);
395 updateProgress();
396 }}
397
398 function handleIframeTimeout(clientId, iframeIndex) {{
399 const iframe = document.getElementById('fc_logout_' + iframeIndex);
400 if (iframe && iframe.style.display !== 'none') {{
401 completedNotifications++;
402 errors.push(clientId + ' (timeout)');
403 log('Logout notification timeout for: ' + clientId);
404 updateProgress();
405 }}
406 }}
407
408 function updateProgress() {{
409 document.getElementById('completed').textContent = completedNotifications;
410
411 if (completedNotifications >= totalNotifications) {{
412 const statusEl = document.getElementById('status');
413 if (errors.length === 0) {{
414 statusEl.textContent = 'Logout complete. All applications have been notified.';
415 statusEl.className = 'status complete';
416 }} else {{
417 statusEl.textContent = 'Logout complete with some errors.';
418 statusEl.className = 'status error';
419 log('Notifications failed for: ' + errors.join(', '));
420 }}
421
422 // Auto-close or redirect after a delay
423 setTimeout(function() {{
424 window.close();
425 }}, 2000);
426 }}
427 }}
428
429 // Set up timeouts for all iframes
430 log('Starting front-channel logout for ' + totalNotifications + ' applications');
431{}
432
433 // Fallback timeout to ensure page doesn't hang
434 setTimeout(function() {{
435 if (completedNotifications < totalNotifications) {{
436 log('Global timeout reached, completing logout process');
437 completedNotifications = totalNotifications;
438 updateProgress();
439 }}
440 }}, {});
441 </script>
442</body>
443</html>"#,
444 iframe_urls.len(),
445 iframes_html,
446 debug_logging,
447 iframe_urls.len(),
448 timeout_scripts,
449 self.config.iframe_timeout_ms * 2 )
451 }
452
453 pub fn cleanup_expired_logouts(&mut self) -> usize {
455 let now = SystemTime::now();
456 let initial_count = self.active_logouts.len();
457
458 self.active_logouts.retain(|_, timestamp| {
459 now.duration_since(*timestamp)
460 .map(|d| d.as_secs() < 3600) .unwrap_or(false)
462 });
463
464 initial_count - self.active_logouts.len()
465 }
466
467 pub fn get_discovery_metadata(&self) -> HashMap<String, serde_json::Value> {
469 let mut metadata = HashMap::new();
470
471 if self.config.enabled {
472 metadata.insert(
473 "frontchannel_logout_supported".to_string(),
474 serde_json::Value::Bool(true),
475 );
476
477 metadata.insert(
478 "frontchannel_logout_session_supported".to_string(),
479 serde_json::Value::Bool(true),
480 );
481 }
482
483 metadata
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::server::oidc::oidc_session_management::SessionManagementConfig;
491
492 fn create_test_manager() -> FrontChannelLogoutManager {
493 let config = FrontChannelLogoutConfig::default();
494 let session_manager = SessionManager::new(SessionManagementConfig::default());
495 FrontChannelLogoutManager::new(config, session_manager)
496 }
497
498 #[test]
499 fn test_frontchannel_url_validation() {
500 let manager = create_test_manager();
501
502 assert!(
504 manager.is_valid_frontchannel_url("https://client.example.com/frontchannel_logout")
505 );
506
507 assert!(manager.is_valid_frontchannel_url("http://localhost:8080/logout"));
509
510 assert!(!manager.is_valid_frontchannel_url("http://example.com/logout"));
512
513 assert!(!manager.is_valid_frontchannel_url("https://example.com/logout\n"));
515 assert!(!manager.is_valid_frontchannel_url("https://example.com/logout<script>"));
516
517 assert!(!manager.is_valid_frontchannel_url(""));
519 }
520
521 #[tokio::test]
522 async fn test_frontchannel_logout_html_generation() {
523 let manager = create_test_manager();
524
525 let iframe_urls = vec![
526 (
527 "client1".to_string(),
528 "https://client1.example.com/logout".to_string(),
529 None,
530 ),
531 (
532 "client2".to_string(),
533 "https://client2.example.com/logout".to_string(),
534 Some(3000),
535 ),
536 ];
537
538 let html = manager.generate_frontchannel_logout_html(&iframe_urls);
539
540 assert!(html.contains("https://client1.example.com/logout"));
541 assert!(html.contains("https://client2.example.com/logout"));
542 assert!(html.contains("fc_logout_0"));
543 assert!(html.contains("fc_logout_1"));
544 assert!(html.contains("of <span id=\"total\">2</span> notifications"));
545 assert!(html.contains("handleIframeLoad"));
546 assert!(html.contains("handleIframeError"));
547 }
548
549 #[test]
550 fn test_frontchannel_logout_url_building() {
551 let manager = create_test_manager();
552
553 let session = OidcSession {
554 session_id: "session123".to_string(),
555 sub: "user123".to_string(),
556 client_id: "client456".to_string(),
557 created_at: 1000000000,
558 last_activity: 1000001000,
559 expires_at: 1000002000,
560 state: crate::server::oidc::oidc_session_management::OidcSessionState::Authenticated,
561 browser_session_id: "browser_session_123".to_string(),
562 logout_tokens: vec![],
563 metadata: HashMap::new(),
564 };
565
566 let rp_config = RpFrontChannelConfig {
567 client_id: "client456".to_string(),
568 frontchannel_logout_uri: "https://client.example.com/fc_logout".to_string(),
569 frontchannel_logout_session_required: true,
570 custom_timeout_ms: None,
571 };
572
573 let logout_request = FrontChannelLogoutRequest {
574 session_id: "other_session".to_string(),
575 sub: "user123".to_string(),
576 sid: Some("sid123".to_string()),
577 iss: "https://op.example.com".to_string(),
578 initiating_client_id: None,
579 };
580
581 let url = manager
582 .build_frontchannel_logout_url(&session, &rp_config, &logout_request)
583 .unwrap();
584
585 assert!(url.contains("https://client.example.com/fc_logout"));
586 assert!(url.contains("iss=https%3A%2F%2Fop.example.com"));
587 assert!(url.contains("sid=sid123"));
588 }
589}