auth_framework/server/oidc/
oidc_frontchannel_logout.rs

1//! OpenID Connect Front-Channel Logout Implementation
2//!
3//! This module implements the "OpenID Connect Front-Channel Logout 1.0" specification,
4//! which allows OpenID Providers to notify Relying Parties about logout events through
5//! front-channel (browser) communication using invisible iframes.
6//!
7//! # Features
8//!
9//! - Front-channel logout notification management
10//! - Invisible iframe-based RP notification
11//! - Session identifier (sid) tracking
12//! - Logout token generation and validation
13//! - Integration with RP-initiated logout
14
15use 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/// Front-channel logout request parameters
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FrontChannelLogoutRequest {
25    /// Session ID being logged out
26    pub session_id: String,
27    /// Subject identifier
28    pub sub: String,
29    /// Session identifier (sid) claim value
30    pub sid: Option<String>,
31    /// Issuer identifier
32    pub iss: String,
33    /// Initiating client ID (if logout was client-initiated)
34    pub initiating_client_id: Option<String>,
35}
36
37/// Front-channel logout response
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct FrontChannelLogoutResponse {
40    /// Whether logout notifications were sent successfully
41    pub success: bool,
42    /// Number of RPs notified
43    pub notified_rps: usize,
44    /// List of RPs that were notified
45    pub notified_clients: Vec<String>,
46    /// List of RPs that failed to be notified
47    pub failed_notifications: Vec<FailedNotification>,
48    /// HTML content for front-channel logout page
49    pub logout_page_html: String,
50}
51
52/// Failed notification information
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct FailedNotification {
55    /// Client ID that failed
56    pub client_id: String,
57    /// Front-channel logout URI that failed
58    pub frontchannel_logout_uri: String,
59    /// Error description
60    pub error: String,
61}
62
63/// Front-channel logout configuration
64#[derive(Debug, Clone)]
65pub struct FrontChannelLogoutConfig {
66    /// Enable front-channel logout
67    pub enabled: bool,
68    /// Maximum time to wait for iframe loads (milliseconds)
69    pub iframe_timeout_ms: u64,
70    /// Maximum number of concurrent iframe notifications
71    pub max_concurrent_notifications: usize,
72    /// Include session_state parameter in logout URIs
73    pub include_session_state: bool,
74    /// Default iframe dimensions
75    pub iframe_width: u32,
76    pub iframe_height: u32,
77    /// Enable JavaScript console logging for debugging
78    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, // 5 seconds
86            max_concurrent_notifications: 10,
87            include_session_state: true,
88            iframe_width: 0,  // Hidden iframe
89            iframe_height: 0, // Hidden iframe
90            enable_debug_logging: false,
91        }
92    }
93}
94
95/// RP front-channel logout configuration
96#[derive(Debug, Clone)]
97pub struct RpFrontChannelConfig {
98    /// Client ID
99    pub client_id: String,
100    /// Front-channel logout URI
101    pub frontchannel_logout_uri: String,
102    /// Whether RP requires session_state parameter
103    pub frontchannel_logout_session_required: bool,
104    /// Custom timeout for this RP (if different from global)
105    pub custom_timeout_ms: Option<u64>,
106}
107
108/// Front-channel logout manager
109#[derive(Debug, Clone)]
110pub struct FrontChannelLogoutManager {
111    /// Configuration
112    config: FrontChannelLogoutConfig,
113    /// Session manager for session tracking
114    session_manager: SessionManager,
115    /// Registered RP configurations
116    rp_configs: HashMap<String, RpFrontChannelConfig>,
117    /// Active logout requests tracking
118    active_logouts: HashMap<String, SystemTime>,
119}
120
121impl FrontChannelLogoutManager {
122    /// Create new front-channel logout manager
123    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    /// Register RP front-channel logout configuration
133    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    /// Process front-channel logout request
139    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        // Find all sessions for the subject
148        let user_sessions = self.session_manager.get_sessions_for_subject(&request.sub);
149
150        // Determine which RPs need to be notified
151        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            // Skip the session being logged out to avoid self-notification
157            if session.session_id == request.session_id {
158                continue;
159            }
160
161            // Check if this client has front-channel logout configured
162            if let Some(rp_config) = self.rp_configs.get(&session.client_id) {
163                // Skip the initiating client if this is a client-initiated logout
164                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        // Generate front-channel logout URLs for each RP
175        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        // Generate the HTML page with hidden iframes
197        let logout_page_html = self.generate_frontchannel_logout_html(&iframe_urls);
198
199        // Track this logout request
200        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    /// Build front-channel logout URL for an RP
213    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        // Add issuer parameter
223        params.push(format!("iss={}", urlencoding::encode(&logout_request.iss)));
224
225        // Add session identifier if available and required/configured
226        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                // Generate sid from session if not provided
231                let sid = format!("sess_{}", &session.session_id[..8]);
232                params.push(format!("sid={}", urlencoding::encode(&sid)));
233            }
234        }
235
236        // Combine URL with parameters
237        let separator = if url.contains('?') { "&" } else { "?" };
238        if !params.is_empty() {
239            url = format!("{}{}{}", url, separator, params.join("&"));
240        }
241
242        // Validate the resulting URL
243        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    /// Validate front-channel logout URL
251    fn is_valid_frontchannel_url(&self, url: &str) -> bool {
252        // Basic URL validation
253        if url.is_empty() {
254            return false;
255        }
256
257        // Must be HTTPS in production (allow HTTP for localhost development)
258        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        // Check for prohibited characters that could cause issues
266        if url.contains('\n') || url.contains('\r') || url.contains('<') || url.contains('>') {
267            return false;
268        }
269
270        true
271    }
272
273    /// Generate HTML page with hidden iframes for front-channel logout
274    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            // Add timeout for each iframe
293            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 // Global timeout is double the iframe timeout
439        )
440    }
441
442    /// Clean up expired logout tracking
443    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) // Keep for 1 hour
450                .unwrap_or(false)
451        });
452
453        initial_count - self.active_logouts.len()
454    }
455
456    /// Get discovery metadata for front-channel logout
457    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        // Valid HTTPS URL
492        assert!(
493            manager.is_valid_frontchannel_url("https://client.example.com/frontchannel_logout")
494        );
495
496        // Valid localhost HTTP (for development)
497        assert!(manager.is_valid_frontchannel_url("http://localhost:8080/logout"));
498
499        // Invalid - not HTTPS and not localhost
500        assert!(!manager.is_valid_frontchannel_url("http://example.com/logout"));
501
502        // Invalid - contains dangerous characters
503        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        // Invalid - empty
507        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