Skip to main content

auth_framework/server/oidc/
oidc_session_management.rs

1//! OpenID Connect Session Management Implementation
2//!
3//! This module provides comprehensive session management capabilities for OpenID Connect,
4//! serving as the foundation for RP-Initiated Logout, Front-Channel Logout,
5//! Back-Channel Logout, and other session-related specifications.
6//!
7//! # Features
8//!
9//! - Session state monitoring
10//! - Session management endpoints
11//! - iframe-based session checking
12//! - Session change notifications
13//! - Multi-tab session coordination
14
15use crate::errors::{AuthError, Result};
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::collections::HashMap;
19use std::time::{SystemTime, UNIX_EPOCH};
20use uuid::Uuid;
21
22/// Session state enumeration
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub enum OidcSessionState {
25    /// User is authenticated
26    Authenticated,
27    /// User is not authenticated
28    Unauthenticated,
29    /// Session state changed
30    Changed,
31    /// Session state unknown/error
32    Unknown,
33}
34
35/// OpenID Connect session information
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OidcSession {
38    /// Unique session identifier
39    pub session_id: String,
40    /// Subject (user) identifier
41    pub sub: String,
42    /// Client ID for this session
43    pub client_id: String,
44    /// Session creation timestamp
45    pub created_at: u64,
46    /// Last activity timestamp
47    pub last_activity: u64,
48    /// Session expiration timestamp
49    pub expires_at: u64,
50    /// Session state
51    pub state: OidcSessionState,
52    /// Browser session identifier (session_state parameter)
53    pub browser_session_id: String,
54    /// Associated logout tokens for backchannel logout
55    pub logout_tokens: Vec<String>,
56    /// Session metadata
57    pub metadata: HashMap<String, String>,
58}
59
60/// Session Management configuration
61#[derive(Debug, Clone)]
62pub struct SessionManagementConfig {
63    /// Enable session management features
64    pub enabled: bool,
65    /// Session timeout in seconds
66    pub session_timeout: u64,
67    /// Check session interval in seconds
68    pub check_session_interval: u64,
69    /// Enable iframe session checking
70    pub enable_iframe_checking: bool,
71    /// Session management endpoints
72    pub check_session_iframe_endpoint: String,
73    /// End session endpoint
74    pub end_session_endpoint: String,
75}
76
77impl Default for SessionManagementConfig {
78    fn default() -> Self {
79        Self {
80            enabled: true,
81            session_timeout: 3600,      // 1 hour
82            check_session_interval: 30, // 30 seconds
83            enable_iframe_checking: true,
84            check_session_iframe_endpoint: "/connect/checksession".to_string(),
85            end_session_endpoint: "/connect/endsession".to_string(),
86        }
87    }
88}
89
90/// Session Management provider
91#[derive(Debug, Clone)]
92pub struct SessionManager {
93    /// Configuration
94    config: SessionManagementConfig,
95    /// Active sessions storage
96    sessions: HashMap<String, OidcSession>,
97}
98
99/// Session check request parameters
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct SessionCheckRequest {
102    /// Client ID
103    pub client_id: String,
104    /// Session state value
105    pub session_state: String,
106    /// Origin for CORS
107    pub origin: String,
108}
109
110/// Session check response
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct SessionCheckResponse {
113    /// Session state
114    pub state: OidcSessionState,
115    /// New session state value if changed
116    pub session_state: Option<String>,
117}
118
119impl SessionManager {
120    /// Create new session manager
121    pub fn new(config: SessionManagementConfig) -> Self {
122        Self {
123            config,
124            sessions: HashMap::new(),
125        }
126    }
127
128    /// Create new session
129    pub fn create_session(
130        &mut self,
131        sub: String,
132        client_id: String,
133        metadata: HashMap<String, String>,
134    ) -> Result<OidcSession> {
135        let now = SystemTime::now()
136            .duration_since(UNIX_EPOCH)
137            .unwrap_or_default()
138            .as_secs();
139
140        let session = OidcSession {
141            session_id: Uuid::new_v4().to_string(),
142            sub: sub.clone(),
143            client_id: client_id.clone(),
144            created_at: now,
145            last_activity: now,
146            expires_at: now + 3600, // Default 1 hour expiration
147            state: OidcSessionState::Authenticated,
148            browser_session_id: self.generate_browser_session_id(&sub, &client_id)?,
149            logout_tokens: Vec::new(),
150            metadata,
151        };
152
153        self.sessions
154            .insert(session.session_id.clone(), session.clone());
155        Ok(session)
156    }
157
158    /// Get session by ID
159    pub fn get_session(&self, session_id: &str) -> Option<&OidcSession> {
160        self.sessions.get(session_id)
161    }
162
163    /// Update session activity
164    pub fn update_session_activity(&mut self, session_id: &str) -> Result<()> {
165        if let Some(session) = self.sessions.get_mut(session_id) {
166            let now = SystemTime::now()
167                .duration_since(UNIX_EPOCH)
168                .unwrap_or_default()
169                .as_secs();
170            session.last_activity = now;
171            Ok(())
172        } else {
173            Err(AuthError::validation("Session not found"))
174        }
175    }
176
177    /// Check if session is valid (not expired)
178    pub fn is_session_valid(&self, session_id: &str) -> bool {
179        if let Some(session) = self.get_session(session_id) {
180            let now = SystemTime::now()
181                .duration_since(UNIX_EPOCH)
182                .unwrap_or_default()
183                .as_secs();
184
185            now - session.last_activity < self.config.session_timeout
186        } else {
187            false
188        }
189    }
190
191    /// Generate an opaque browser session ID for the OIDC `session_state` parameter.
192    ///
193    /// Uses SHA-256(sub || client_id || random UUID) so the result is a fixed-length
194    /// opaque token that does not leak user identity or client information in the URL.
195    fn generate_browser_session_id(&self, sub: &str, client_id: &str) -> Result<String> {
196        let nonce = Uuid::new_v4().to_string();
197        let mut hasher = Sha256::new();
198        hasher.update(sub.as_bytes());
199        hasher.update(b":");
200        hasher.update(client_id.as_bytes());
201        hasher.update(b":");
202        hasher.update(nonce.as_bytes());
203        let hash = hasher.finalize();
204        Ok(hex::encode(hash))
205    }
206
207    /// Check session state for iframe polling
208    pub fn check_session_state(
209        &self,
210        request: SessionCheckRequest,
211    ) -> Result<SessionCheckResponse> {
212        // Find session by browser session ID
213        let session = self.sessions.values().find(|s| {
214            s.browser_session_id == request.session_state && s.client_id == request.client_id
215        });
216
217        if let Some(session) = session {
218            if self.is_session_valid(&session.session_id) {
219                Ok(SessionCheckResponse {
220                    state: OidcSessionState::Authenticated,
221                    session_state: None, // No change
222                })
223            } else {
224                Ok(SessionCheckResponse {
225                    state: OidcSessionState::Unauthenticated,
226                    session_state: None,
227                })
228            }
229        } else {
230            Ok(SessionCheckResponse {
231                state: OidcSessionState::Unauthenticated,
232                session_state: None,
233            })
234        }
235    }
236
237    /// End session (logout)
238    pub fn end_session(&mut self, session_id: &str) -> Result<OidcSession> {
239        if let Some(mut session) = self.sessions.remove(session_id) {
240            session.state = OidcSessionState::Unauthenticated;
241            Ok(session)
242        } else {
243            Err(AuthError::validation("Session not found"))
244        }
245    }
246
247    /// Get check session iframe HTML
248    pub fn get_check_session_iframe(&self, client_id: &str) -> String {
249        format!(
250            r#"<!DOCTYPE html>
251<html>
252<head>
253    <title>Session Check</title>
254    <script>
255        (function() {{
256            var client_id = "{}";
257            var check_interval = {} * 1000; // Convert to milliseconds
258
259            function getCookie(name) {{
260                var cookies = document.cookie.split(';');
261                for (var i = 0; i < cookies.length; i++) {{
262                    var cookie = cookies[i].trim();
263                    if (cookie.indexOf(name + '=') === 0) {{
264                        return cookie.substring(name.length + 1);
265                    }}
266                }}
267                return null;
268            }}
269
270            function checkSession() {{
271                var OidcSessionState = getCookie('session_state');
272                if (OidcSessionState) {{
273                    // Notify parent window of session check
274                    window.parent.postMessage({{
275                        type: 'session_check',
276                        client_id: client_id,
277                        session_state: OidcSessionState,
278                        state: 'unchanged'
279                    }}, '*');
280                }} else {{
281                    window.parent.postMessage({{
282                        type: 'session_check',
283                        client_id: client_id,
284                        state: 'unauthenticated'
285                    }}, '*');
286                }}
287            }}
288
289            // Initial check
290            checkSession();
291
292            // Periodic checking
293            setInterval(checkSession, check_interval);
294
295            // Listen for messages from parent
296            window.addEventListener('message', function(e) {{
297                if (e.data && e.data.type === 'check_session') {{
298                    checkSession();
299                }}
300            }});
301        }})();
302    </script>
303</head>
304<body>
305    <p>Session monitoring active...</p>
306</body>
307</html>"#,
308            client_id, self.config.check_session_interval
309        )
310    }
311
312    /// Clean up expired sessions
313    pub fn cleanup_expired_sessions(&mut self) -> usize {
314        let now = SystemTime::now()
315            .duration_since(UNIX_EPOCH)
316            .unwrap_or_default()
317            .as_secs();
318
319        let initial_count = self.sessions.len();
320
321        self.sessions
322            .retain(|_, session| now - session.last_activity < self.config.session_timeout);
323
324        initial_count - self.sessions.len()
325    }
326
327    /// Get all sessions for a subject
328    pub fn get_sessions_for_subject(&self, sub: &str) -> Vec<&OidcSession> {
329        self.sessions
330            .values()
331            .filter(|session| session.sub == sub)
332            .collect()
333    }
334
335    /// Add logout token to session (for backchannel logout)
336    pub fn add_logout_token(&mut self, session_id: &str, logout_token: String) -> Result<()> {
337        if let Some(session) = self.sessions.get_mut(session_id) {
338            session.logout_tokens.push(logout_token);
339            Ok(())
340        } else {
341            Err(AuthError::validation("Session not found"))
342        }
343    }
344
345    /// Get session management discovery metadata
346    pub fn get_discovery_metadata(&self) -> HashMap<String, serde_json::Value> {
347        let mut metadata = HashMap::new();
348
349        if self.config.enabled {
350            metadata.insert(
351                "check_session_iframe".to_string(),
352                serde_json::Value::String(self.config.check_session_iframe_endpoint.clone()),
353            );
354            metadata.insert(
355                "end_session_endpoint".to_string(),
356                serde_json::Value::String(self.config.end_session_endpoint.clone()),
357            );
358            metadata.insert(
359                "frontchannel_logout_supported".to_string(),
360                serde_json::Value::Bool(true),
361            );
362            metadata.insert(
363                "frontchannel_logout_session_supported".to_string(),
364                serde_json::Value::Bool(true),
365            );
366            metadata.insert(
367                "backchannel_logout_supported".to_string(),
368                serde_json::Value::Bool(true),
369            );
370            metadata.insert(
371                "backchannel_logout_session_supported".to_string(),
372                serde_json::Value::Bool(true),
373            );
374        }
375
376        metadata
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn test_session_creation() {
386        let mut manager = SessionManager::new(SessionManagementConfig::default());
387
388        let mut metadata = HashMap::new();
389        metadata.insert("ip_address".to_string(), "192.168.1.1".to_string());
390
391        let session = manager
392            .create_session("user123".to_string(), "client456".to_string(), metadata)
393            .unwrap();
394
395        assert_eq!(session.sub, "user123");
396        assert_eq!(session.client_id, "client456");
397        assert_eq!(session.state, OidcSessionState::Authenticated);
398        assert!(!session.browser_session_id.is_empty());
399    }
400
401    #[test]
402    fn test_session_validity() {
403        let mut manager = SessionManager::new(SessionManagementConfig {
404            session_timeout: 1, // 1 second timeout for testing
405            ..SessionManagementConfig::default()
406        });
407
408        let session = manager
409            .create_session(
410                "user123".to_string(),
411                "client456".to_string(),
412                HashMap::new(),
413            )
414            .unwrap();
415
416        assert!(manager.is_session_valid(&session.session_id));
417
418        // Wait for timeout
419        std::thread::sleep(std::time::Duration::from_secs(2));
420
421        assert!(!manager.is_session_valid(&session.session_id));
422    }
423
424    #[test]
425    fn test_check_session_iframe_generation() {
426        let manager = SessionManager::new(SessionManagementConfig::default());
427
428        let html = manager.get_check_session_iframe("test_client");
429
430        assert!(html.contains("test_client"));
431        assert!(html.contains("session_check"));
432        assert!(html.contains("30 * 1000")); // Default interval
433    }
434}