Skip to main content

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        /// HTML-escape a string for safe insertion into HTML attributes/content
279        fn html_escape(s: &str) -> String {
280            s.replace('&', "&amp;")
281                .replace('"', "&quot;")
282                .replace('\'', "&#x27;")
283                .replace('<', "&lt;")
284                .replace('>', "&gt;")
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            // Add timeout for each iframe
304            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 // Global timeout is double the iframe timeout
450        )
451    }
452
453    /// Clean up expired logout tracking
454    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) // Keep for 1 hour
461                .unwrap_or(false)
462        });
463
464        initial_count - self.active_logouts.len()
465    }
466
467    /// Get discovery metadata for front-channel logout
468    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        // Valid HTTPS URL
503        assert!(
504            manager.is_valid_frontchannel_url("https://client.example.com/frontchannel_logout")
505        );
506
507        // Valid localhost HTTP (for development)
508        assert!(manager.is_valid_frontchannel_url("http://localhost:8080/logout"));
509
510        // Invalid - not HTTPS and not localhost
511        assert!(!manager.is_valid_frontchannel_url("http://example.com/logout"));
512
513        // Invalid - contains dangerous characters
514        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        // Invalid - empty
518        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}