auth_framework/server/oidc/
oidc_frontchannel_logout.rs1use 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 let mut iframes_html = String::new();
279 let mut timeout_scripts = String::new();
280
281 for (i, (client_id, url, custom_timeout)) in iframe_urls.iter().enumerate() {
282 let timeout = custom_timeout.unwrap_or(self.config.iframe_timeout_ms);
283
284 iframes_html.push_str(&format!(
285 r#" <iframe id="fc_logout_{}" src="{}" width="{}" height="{}" style="display:none; visibility:hidden;"
286 onload="handleIframeLoad('{}')"
287 onerror="handleIframeError('{}')"></iframe>
288"#,
289 i, url, self.config.iframe_width, self.config.iframe_height, client_id, client_id
290 ));
291
292 timeout_scripts.push_str(&format!(
294 r#" setTimeout(function() {{
295 handleIframeTimeout('{}', {});
296 }}, {});
297"#,
298 client_id, i, timeout
299 ));
300 }
301
302 let debug_logging = if self.config.enable_debug_logging {
303 "const DEBUG_LOGGING = true;"
304 } else {
305 "const DEBUG_LOGGING = false;"
306 };
307
308 format!(
309 r#"<!DOCTYPE html>
310<html>
311<head>
312 <title>Logging Out...</title>
313 <style>
314 body {{
315 font-family: Arial, sans-serif;
316 margin: 40px;
317 text-align: center;
318 background-color: #f8f9fa;
319 }}
320 .logout-container {{
321 max-width: 400px;
322 margin: 50px auto;
323 background: white;
324 padding: 40px;
325 border-radius: 8px;
326 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
327 }}
328 .spinner {{
329 border: 4px solid #f3f3f3;
330 border-top: 4px solid #007bff;
331 border-radius: 50%;
332 width: 50px;
333 height: 50px;
334 animation: spin 1s linear infinite;
335 margin: 20px auto;
336 }}
337 @keyframes spin {{
338 0% {{ transform: rotate(0deg); }}
339 100% {{ transform: rotate(360deg); }}
340 }}
341 .status {{ margin-top: 20px; color: #666; }}
342 .complete {{ color: #28a745; font-weight: bold; }}
343 .error {{ color: #dc3545; }}
344 </style>
345</head>
346<body>
347 <div class="logout-container">
348 <h1>🔐 Logging Out</h1>
349 <div class="spinner"></div>
350 <div id="status" class="status">
351 Notifying applications of logout...
352 </div>
353 <div id="progress" class="status">
354 <span id="completed">0</span> of <span id="total">{}</span> notifications sent
355 </div>
356 </div>
357
358 <!-- Hidden iframes for front-channel logout notifications -->
359{}
360
361 <script>
362 {}
363
364 let completedNotifications = 0;
365 let totalNotifications = {};
366 let errors = [];
367
368 function log(message) {{
369 if (DEBUG_LOGGING) {{
370 console.log('[FrontChannel Logout] ' + message);
371 }}
372 }}
373
374 function handleIframeLoad(clientId) {{
375 completedNotifications++;
376 log('Logout notification sent to: ' + clientId);
377 updateProgress();
378 }}
379
380 function handleIframeError(clientId) {{
381 completedNotifications++;
382 errors.push(clientId);
383 log('Logout notification failed for: ' + clientId);
384 updateProgress();
385 }}
386
387 function handleIframeTimeout(clientId, iframeIndex) {{
388 const iframe = document.getElementById('fc_logout_' + iframeIndex);
389 if (iframe && iframe.style.display !== 'none') {{
390 completedNotifications++;
391 errors.push(clientId + ' (timeout)');
392 log('Logout notification timeout for: ' + clientId);
393 updateProgress();
394 }}
395 }}
396
397 function updateProgress() {{
398 document.getElementById('completed').textContent = completedNotifications;
399
400 if (completedNotifications >= totalNotifications) {{
401 const statusEl = document.getElementById('status');
402 if (errors.length === 0) {{
403 statusEl.textContent = 'Logout complete. All applications have been notified.';
404 statusEl.className = 'status complete';
405 }} else {{
406 statusEl.textContent = 'Logout complete with some errors.';
407 statusEl.className = 'status error';
408 log('Notifications failed for: ' + errors.join(', '));
409 }}
410
411 // Auto-close or redirect after a delay
412 setTimeout(function() {{
413 window.close();
414 }}, 2000);
415 }}
416 }}
417
418 // Set up timeouts for all iframes
419 log('Starting front-channel logout for ' + totalNotifications + ' applications');
420{}
421
422 // Fallback timeout to ensure page doesn't hang
423 setTimeout(function() {{
424 if (completedNotifications < totalNotifications) {{
425 log('Global timeout reached, completing logout process');
426 completedNotifications = totalNotifications;
427 updateProgress();
428 }}
429 }}, {});
430 </script>
431</body>
432</html>"#,
433 iframe_urls.len(),
434 iframes_html,
435 debug_logging,
436 iframe_urls.len(),
437 timeout_scripts,
438 self.config.iframe_timeout_ms * 2 )
440 }
441
442 pub fn cleanup_expired_logouts(&mut self) -> usize {
444 let now = SystemTime::now();
445 let initial_count = self.active_logouts.len();
446
447 self.active_logouts.retain(|_, timestamp| {
448 now.duration_since(*timestamp)
449 .map(|d| d.as_secs() < 3600) .unwrap_or(false)
451 });
452
453 initial_count - self.active_logouts.len()
454 }
455
456 pub fn get_discovery_metadata(&self) -> HashMap<String, serde_json::Value> {
458 let mut metadata = HashMap::new();
459
460 if self.config.enabled {
461 metadata.insert(
462 "frontchannel_logout_supported".to_string(),
463 serde_json::Value::Bool(true),
464 );
465
466 metadata.insert(
467 "frontchannel_logout_session_supported".to_string(),
468 serde_json::Value::Bool(true),
469 );
470 }
471
472 metadata
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::server::oidc::oidc_session_management::SessionManagementConfig;
480
481 fn create_test_manager() -> FrontChannelLogoutManager {
482 let config = FrontChannelLogoutConfig::default();
483 let session_manager = SessionManager::new(SessionManagementConfig::default());
484 FrontChannelLogoutManager::new(config, session_manager)
485 }
486
487 #[test]
488 fn test_frontchannel_url_validation() {
489 let manager = create_test_manager();
490
491 assert!(
493 manager.is_valid_frontchannel_url("https://client.example.com/frontchannel_logout")
494 );
495
496 assert!(manager.is_valid_frontchannel_url("http://localhost:8080/logout"));
498
499 assert!(!manager.is_valid_frontchannel_url("http://example.com/logout"));
501
502 assert!(!manager.is_valid_frontchannel_url("https://example.com/logout\n"));
504 assert!(!manager.is_valid_frontchannel_url("https://example.com/logout<script>"));
505
506 assert!(!manager.is_valid_frontchannel_url(""));
508 }
509
510 #[tokio::test]
511 async fn test_frontchannel_logout_html_generation() {
512 let manager = create_test_manager();
513
514 let iframe_urls = vec![
515 (
516 "client1".to_string(),
517 "https://client1.example.com/logout".to_string(),
518 None,
519 ),
520 (
521 "client2".to_string(),
522 "https://client2.example.com/logout".to_string(),
523 Some(3000),
524 ),
525 ];
526
527 let html = manager.generate_frontchannel_logout_html(&iframe_urls);
528
529 println!("Generated HTML: {}", html);
530
531 assert!(html.contains("https://client1.example.com/logout"));
532 assert!(html.contains("https://client2.example.com/logout"));
533 assert!(html.contains("fc_logout_0"));
534 assert!(html.contains("fc_logout_1"));
535 assert!(html.contains("of <span id=\"total\">2</span> notifications"));
536 assert!(html.contains("handleIframeLoad"));
537 assert!(html.contains("handleIframeError"));
538 }
539
540 #[test]
541 fn test_frontchannel_logout_url_building() {
542 let manager = create_test_manager();
543
544 let session = OidcSession {
545 session_id: "session123".to_string(),
546 sub: "user123".to_string(),
547 client_id: "client456".to_string(),
548 created_at: 1000000000,
549 last_activity: 1000001000,
550 expires_at: 1000002000,
551 state: crate::server::oidc::oidc_session_management::SessionState::Authenticated,
552 browser_session_id: "browser_session_123".to_string(),
553 logout_tokens: vec![],
554 metadata: HashMap::new(),
555 };
556
557 let rp_config = RpFrontChannelConfig {
558 client_id: "client456".to_string(),
559 frontchannel_logout_uri: "https://client.example.com/fc_logout".to_string(),
560 frontchannel_logout_session_required: true,
561 custom_timeout_ms: None,
562 };
563
564 let logout_request = FrontChannelLogoutRequest {
565 session_id: "other_session".to_string(),
566 sub: "user123".to_string(),
567 sid: Some("sid123".to_string()),
568 iss: "https://op.example.com".to_string(),
569 initiating_client_id: None,
570 };
571
572 let url = manager
573 .build_frontchannel_logout_url(&session, &rp_config, &logout_request)
574 .unwrap();
575
576 assert!(url.contains("https://client.example.com/fc_logout"));
577 assert!(url.contains("iss=https%3A%2F%2Fop.example.com"));
578 assert!(url.contains("sid=sid123"));
579 }
580}
581
582