Skip to main content

auth_framework/server/oidc/
oidc_backchannel_logout.rs

1//! OpenID Connect Back-Channel Logout Implementation
2//!
3//! This module implements the "OpenID Connect Back-Channel Logout 1.0" specification,
4//! which allows OpenID Providers to notify Relying Parties about logout events through
5//! back-channel (server-to-server) communication using JWT-based logout tokens.
6//!
7//! # Features
8//!
9//! - Back-channel logout token generation and validation
10//! - Server-to-server HTTP POST notifications
11//! - JWT-based logout token with standard claims
12//! - Asynchronous RP notification with retry logic
13//! - Integration with front-channel and 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 tokio::time::Duration;
21use uuid::Uuid;
22
23/// Back-channel logout request parameters
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BackChannelLogoutRequest {
26    /// Session ID being logged out
27    pub session_id: String,
28    /// Subject identifier
29    pub sub: String,
30    /// Session identifier (sid) claim value
31    pub sid: Option<String>,
32    /// Issuer identifier
33    pub iss: String,
34    /// Initiating client ID (if logout was client-initiated)
35    pub initiating_client_id: Option<String>,
36    /// Additional events to include in logout token
37    pub additional_events: Option<HashMap<String, serde_json::Value>>,
38}
39
40/// Back-channel logout response
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct BackChannelLogoutResponse {
43    /// Whether logout notifications were sent successfully
44    pub success: bool,
45    /// Number of RPs notified
46    pub notified_rps: usize,
47    /// List of RPs that were notified successfully
48    pub successful_notifications: Vec<NotificationResult>,
49    /// List of RPs that failed to be notified
50    pub failed_notifications: Vec<FailedNotification>,
51    /// Generated logout token (for debugging/logging)
52    pub logout_token_jti: String,
53}
54
55/// Successful notification result
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct NotificationResult {
58    /// Client ID that was notified
59    pub client_id: String,
60    /// Back-channel logout URI used
61    pub backchannel_logout_uri: String,
62    /// Whether the notification was successful
63    pub success: bool,
64    /// HTTP status code received
65    pub status_code: Option<u16>,
66    /// Number of retry attempts made
67    pub retry_attempts: u32,
68    /// Response time in milliseconds
69    pub response_time_ms: u64,
70}
71
72/// Failed notification information
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct FailedNotification {
75    /// Client ID that failed
76    pub client_id: String,
77    /// Back-channel logout URI that failed
78    pub backchannel_logout_uri: String,
79    /// Error description
80    pub error: String,
81    /// HTTP status code if available
82    pub status_code: Option<u16>,
83    /// Number of retry attempts made
84    pub retry_attempts: u32,
85}
86
87/// Back-channel logout configuration
88#[derive(Debug, Clone)]
89pub struct BackChannelLogoutConfig {
90    /// Enable back-channel logout
91    pub enabled: bool,
92    /// Base URL for endpoints
93    pub base_url: Option<String>,
94    /// Request timeout in seconds
95    pub request_timeout_secs: u64,
96    /// Maximum retry attempts for failed requests
97    pub max_retry_attempts: u32,
98    /// Retry delay in milliseconds (exponential backoff base)
99    pub retry_delay_ms: u64,
100    /// Maximum concurrent notifications
101    pub max_concurrent_notifications: usize,
102    /// Logout token expiration time in seconds
103    pub logout_token_exp_secs: u64,
104    /// Include additional claims in logout token
105    pub include_session_claims: bool,
106    /// Custom User-Agent for HTTP requests
107    pub user_agent: String,
108    /// Enable request/response logging
109    pub enable_http_logging: bool,
110    /// HMAC signing key for logout tokens (generated randomly if not provided).
111    /// Used only when `rsa_private_key_pem` is `None`; in that case the JWT
112    /// header algorithm is `HS256` (not `RS256`).
113    pub signing_key: Option<Vec<u8>>,
114    /// PEM-encoded RSA private key for signing logout tokens with RS256.
115    /// When set, JWTs use `"alg": "RS256"` and are signed with this key.
116    /// Falls back to `signing_key` HMAC if `None`.
117    pub rsa_private_key_pem: Option<String>,
118}
119
120impl Default for BackChannelLogoutConfig {
121    fn default() -> Self {
122        Self {
123            enabled: true,
124            base_url: None,
125            request_timeout_secs: 30,
126            max_retry_attempts: 3,
127            retry_delay_ms: 1000, // Start with 1 second, exponential backoff
128            max_concurrent_notifications: 10,
129            logout_token_exp_secs: 120, // 2 minutes
130            include_session_claims: true,
131            user_agent: "AuthFramework-OIDC/1.0".to_string(),
132            enable_http_logging: false,
133            signing_key: None,
134            rsa_private_key_pem: None,
135        }
136    }
137}
138
139/// RP back-channel logout configuration
140#[derive(Debug, Clone)]
141pub struct RpBackChannelConfig {
142    /// Client ID
143    pub client_id: String,
144    /// Back-channel logout URI
145    pub backchannel_logout_uri: String,
146    /// Whether RP requires session_state parameter
147    pub backchannel_logout_session_required: bool,
148    /// Custom timeout for this RP (if different from global)
149    pub custom_timeout_secs: Option<u64>,
150    /// Custom retry configuration for this RP
151    pub custom_max_retries: Option<u32>,
152    /// Authentication method for back-channel requests (for future use)
153    pub authentication_method: Option<String>,
154}
155
156/// Logout token claims
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct LogoutTokenClaims {
159    /// Issuer
160    pub iss: String,
161    /// Subject
162    pub sub: Option<String>,
163    /// Audience (client_id)
164    pub aud: Vec<String>,
165    /// Issued at
166    pub iat: u64,
167    /// JWT ID
168    pub jti: String,
169    /// Events claim
170    pub events: LogoutEvents,
171    /// Session ID (if available)
172    pub sid: Option<String>,
173    /// Expiration time
174    pub exp: u64,
175}
176
177/// Logout events structure
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct LogoutEvents {
180    /// Back-channel logout event URI
181    #[serde(
182        rename = "http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
183    )]
184    pub backchannel_logout: Option<serde_json::Value>,
185
186    /// Standard logout event
187    #[serde(rename = "http://schemas.openid.net/secevent/oauth/event-type/token-revocation")]
188    pub token_revocation: Option<serde_json::Value>,
189}
190
191/// Back-channel logout manager
192#[derive(Debug)]
193pub struct BackChannelLogoutManager {
194    /// Configuration
195    config: BackChannelLogoutConfig,
196    /// Session manager for session tracking
197    session_manager: SessionManager,
198    /// HTTP client for back-channel requests
199    http_client: crate::server::core::common_http::HttpClient,
200    /// Registered RP configurations
201    rp_configs: HashMap<String, RpBackChannelConfig>,
202    /// Active logout requests tracking
203    active_logouts: HashMap<String, SystemTime>,
204    /// HMAC signing key for logout tokens (fallback when no RSA key)
205    signing_key: Vec<u8>,
206    /// RSA signing key for RS256 logout tokens (preferred)
207    rsa_signing_key: Option<ring::signature::RsaKeyPair>,
208}
209
210impl BackChannelLogoutManager {
211    /// Create new back-channel logout manager
212    pub fn new(config: BackChannelLogoutConfig, session_manager: SessionManager) -> Result<Self> {
213        use crate::server::core::common_config::{EndpointConfig, SecurityConfig, TimeoutConfig};
214
215        // Create endpoint configuration from config
216        let mut endpoint_config = EndpointConfig::new(
217            config
218                .base_url
219                .as_ref()
220                .unwrap_or(&"http://localhost:8080".to_string()),
221        );
222        endpoint_config.timeout = TimeoutConfig {
223            connect_timeout: Duration::from_secs(config.request_timeout_secs),
224            read_timeout: Duration::from_secs(config.request_timeout_secs),
225            write_timeout: Duration::from_secs(30),
226        };
227        endpoint_config.security = SecurityConfig {
228            enable_tls: true,
229            min_tls_version: "1.2".to_string(),
230            cipher_suites: vec![
231                "TLS_AES_256_GCM_SHA384".to_string(),
232                "TLS_CHACHA20_POLY1305_SHA256".to_string(),
233                "TLS_AES_128_GCM_SHA256".to_string(),
234            ],
235            cert_validation: crate::server::core::common_config::CertificateValidation::Full,
236            verify_certificates: true,
237            accept_invalid_certs: false,
238        };
239        endpoint_config
240            .headers
241            .insert("User-Agent".to_string(), config.user_agent.clone());
242
243        let http_client = crate::server::core::common_http::HttpClient::new(endpoint_config)?;
244
245        // Use provided signing key or generate a random one
246        let signing_key = config.signing_key.clone().unwrap_or_else(|| {
247            use ring::rand::{SecureRandom, SystemRandom};
248            let rng = SystemRandom::new();
249            let mut key = vec![0u8; 32];
250            rng.fill(&mut key)
251                .expect("AuthFramework fatal: system CSPRNG unavailable");
252            key
253        });
254
255        // Parse RSA private key if provided
256        let rsa_signing_key = config.rsa_private_key_pem.as_ref().and_then(|pem| {
257            // Strip PEM header/footer and decode
258            let der = pem
259                .lines()
260                .filter(|l| !l.starts_with("-----"))
261                .collect::<String>();
262            let der_bytes = base64::Engine::decode(
263                &base64::engine::general_purpose::STANDARD,
264                &der,
265            ).ok()?;
266            ring::signature::RsaKeyPair::from_pkcs8(&der_bytes)
267                .or_else(|_| ring::signature::RsaKeyPair::from_der(&der_bytes))
268                .ok()
269        });
270
271        Ok(Self {
272            config,
273            session_manager,
274            http_client,
275            rp_configs: HashMap::new(),
276            active_logouts: HashMap::new(),
277            signing_key,
278            rsa_signing_key,
279        })
280    }
281
282    /// Register RP back-channel logout configuration
283    pub fn register_rp_config(&mut self, rp_config: RpBackChannelConfig) {
284        self.rp_configs
285            .insert(rp_config.client_id.clone(), rp_config);
286    }
287
288    /// Process back-channel logout request
289    pub async fn process_backchannel_logout(
290        &mut self,
291        request: BackChannelLogoutRequest,
292    ) -> Result<BackChannelLogoutResponse> {
293        if !self.config.enabled {
294            return Err(AuthError::validation("Back-channel logout is not enabled"));
295        }
296
297        // Find all sessions for the subject
298        let user_sessions = self.session_manager.get_sessions_for_subject(&request.sub);
299
300        // Determine which RPs need to be notified
301        let mut rps_to_notify = Vec::new();
302        for session in user_sessions {
303            // Skip the session being logged out to avoid self-notification
304            if session.session_id == request.session_id {
305                continue;
306            }
307
308            // Check if this client has back-channel logout configured
309            if let Some(rp_config) = self.rp_configs.get(&session.client_id) {
310                // Skip the initiating client if this is a client-initiated logout
311                if let Some(ref initiating_client) = request.initiating_client_id
312                    && &session.client_id == initiating_client
313                {
314                    continue;
315                }
316
317                rps_to_notify.push((session.clone(), rp_config.clone()));
318            }
319        }
320
321        // Generate proper JWT logout token according to OIDC Back-Channel Logout spec
322        let logout_token_jti = Uuid::new_v4().to_string();
323        let logout_token = self
324            .generate_logout_token(&request, &logout_token_jti)
325            .map_err(|e| {
326                AuthError::validation(format!("Failed to generate logout token: {}", e))
327            })?;
328
329        // Send notifications to all RPs concurrently (with concurrency limit)
330        let mut successful_notifications = Vec::new();
331        let mut failed_notifications = Vec::new();
332
333        // Process notifications in batches to respect concurrency limits
334        let chunk_size = self.config.max_concurrent_notifications;
335        for chunk in rps_to_notify.chunks(chunk_size) {
336            let mut tasks = Vec::new();
337
338            for (session, rp_config) in chunk {
339                let logout_token_clone = logout_token.clone();
340                let rp_config_clone = rp_config.clone();
341                let session_clone = session.clone();
342                let client_clone = self.http_client.clone();
343                let config_clone = self.config.clone();
344
345                let task = tokio::spawn(async move {
346                    Self::send_backchannel_notification(
347                        client_clone,
348                        config_clone,
349                        session_clone,
350                        rp_config_clone,
351                        logout_token_clone,
352                    )
353                    .await
354                });
355
356                tasks.push(task);
357            }
358
359            // Wait for all tasks in this batch to complete
360            for task in tasks {
361                match task.await {
362                    Ok(Ok(notification_result)) => {
363                        successful_notifications.push(notification_result);
364                    }
365                    Ok(Err(failed_notification)) => {
366                        failed_notifications.push(failed_notification);
367                    }
368                    Err(e) => {
369                        failed_notifications.push(FailedNotification {
370                            client_id: "unknown".to_string(),
371                            backchannel_logout_uri: "unknown".to_string(),
372                            error: format!("Task execution failed: {}", e),
373                            status_code: None,
374                            retry_attempts: 0,
375                        });
376                    }
377                }
378            }
379        }
380
381        // Track this logout request
382        self.active_logouts
383            .insert(logout_token_jti.clone(), SystemTime::now());
384
385        Ok(BackChannelLogoutResponse {
386            success: failed_notifications.is_empty(),
387            notified_rps: successful_notifications.len(),
388            successful_notifications,
389            failed_notifications,
390            logout_token_jti,
391        })
392    }
393
394    /// Generate logout token JWT (production implementation)
395    ///
396    /// This method creates RFC-compliant OIDC Back-Channel Logout tokens with:
397    /// - Standard logout event claims (iss, sub, aud, iat, jti, events)
398    /// - Support for additional custom events via BackChannelLogoutRequest.additional_events
399    /// - Proper JWT structure (header.payload.signature)
400    /// - Event data validation using the serde_from_value helper function
401    fn generate_logout_token(
402        &self,
403        request: &BackChannelLogoutRequest,
404        jti: &str,
405    ) -> Result<String> {
406        use base64::Engine as _;
407        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
408
409        // Create proper logout token claims according to OIDC Back-Channel Logout spec
410        let now = chrono::Utc::now().timestamp();
411
412        // Build events claim with standard logout event
413        let mut events = serde_json::json!({
414            "http://schemas.openid.net/secevent/oauth/event-type/logout": {}
415        });
416
417        // Add additional events if provided, using our helper function for validation
418        if let Some(ref additional_events) = request.additional_events {
419            for (event_type, event_data) in additional_events {
420                // Validate and deserialize additional event data using our helper
421                let validated_event = serde_from_value::<serde_json::Value>(event_data.clone())?;
422                events[event_type] = validated_event;
423            }
424        }
425
426        let claims = serde_json::json!({
427            "iss": request.iss,
428            "sub": request.sub,
429            "aud": request.initiating_client_id.as_ref().unwrap_or(&"default_client".to_string()),
430            "iat": now,
431            "jti": jti,
432            "events": events,
433            // Note: 'nonce' should NOT be included in logout tokens per spec
434        });
435
436        // Select algorithm based on whether an RSA key is available
437        let alg = if self.rsa_signing_key.is_some() {
438            "RS256"
439        } else {
440            "HS256"
441        };
442
443        // Create JWT header — algorithm MUST match the actual signing method
444        let header = serde_json::json!({
445            "alg": alg,
446            "typ": "logout+jwt",
447        });
448
449        // Encode header and payload
450        let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
451        let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
452        let signing_input = format!("{}.{}", header_b64, claims_b64);
453
454        let signature = self.generate_logout_token_signature(&signing_input)?;
455        let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
456
457        Ok(format!("{}.{}.{}", header_b64, claims_b64, signature_b64))
458    }
459
460    /// Sign the logout token using RSA (RS256) if available, otherwise HMAC (HS256).
461    fn generate_logout_token_signature(&self, signing_input: &str) -> Result<Vec<u8>> {
462        if let Some(ref rsa_key) = self.rsa_signing_key {
463            // RS256: RSA PKCS#1 v1.5 with SHA-256
464            let rng = ring::rand::SystemRandom::new();
465            let mut sig = vec![0u8; rsa_key.public().modulus_len()];
466            rsa_key
467                .sign(
468                    &ring::signature::RSA_PKCS1_SHA256,
469                    &rng,
470                    signing_input.as_bytes(),
471                    &mut sig,
472                )
473                .map_err(|e| AuthError::internal(format!("RSA signing error: {e}")))?;
474            Ok(sig)
475        } else {
476            // Fallback: HS256
477            use hmac::{Hmac, Mac};
478            use sha2::Sha256;
479            type HmacSha256 = Hmac<Sha256>;
480            let mut mac = HmacSha256::new_from_slice(&self.signing_key)
481                .map_err(|e| AuthError::internal(format!("HMAC key error: {}", e)))?;
482            mac.update(signing_input.as_bytes());
483            Ok(mac.finalize().into_bytes().to_vec())
484        }
485    }
486
487    /// Send back-channel logout notification to a specific RP
488    async fn send_backchannel_notification(
489        client: crate::server::core::common_http::HttpClient,
490        config: BackChannelLogoutConfig,
491        session: OidcSession,
492        rp_config: RpBackChannelConfig,
493        logout_token: String,
494    ) -> Result<NotificationResult, FailedNotification> {
495        use std::collections::HashMap;
496
497        let client_id = session.client_id.clone();
498        let backchannel_logout_uri = rp_config.backchannel_logout_uri.clone();
499
500        // Prepare form data for the logout token
501        let mut form_data = HashMap::new();
502        form_data.insert("logout_token".to_string(), logout_token);
503
504        let mut retry_count = 0;
505        let max_retries = config.max_retry_attempts;
506        let start_time = std::time::Instant::now();
507
508        loop {
509            // Send POST request with form data
510            let response = client.post_form(&backchannel_logout_uri, &form_data).await;
511
512            match response {
513                Ok(resp) => {
514                    let status_code = resp.status().as_u16();
515                    let response_time = start_time.elapsed().as_millis() as u64;
516
517                    if resp.status().is_success() {
518                        return Ok(NotificationResult {
519                            client_id,
520                            backchannel_logout_uri,
521                            success: true,
522                            status_code: Some(status_code),
523                            retry_attempts: retry_count,
524                            response_time_ms: response_time,
525                        });
526                    } else if retry_count < max_retries && Self::is_retryable_status(status_code) {
527                        // Retry for retryable errors
528                        retry_count += 1;
529                        let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
530                        tokio::time::sleep(delay).await;
531                        continue;
532                    } else {
533                        let body = resp.text().await.unwrap_or_default();
534                        return Err(FailedNotification {
535                            client_id,
536                            backchannel_logout_uri,
537                            error: format!("HTTP {}: {}", status_code, body),
538                            status_code: Some(status_code),
539                            retry_attempts: retry_count,
540                        });
541                    }
542                }
543                Err(e) => {
544                    if retry_count < max_retries {
545                        retry_count += 1;
546                        let delay = Duration::from_millis(100 * (2_u64.pow(retry_count)));
547                        tokio::time::sleep(delay).await;
548                        continue;
549                    } else {
550                        return Err(FailedNotification {
551                            client_id,
552                            backchannel_logout_uri,
553                            error: format!("Request failed: {}", e),
554                            status_code: None,
555                            retry_attempts: retry_count,
556                        });
557                    }
558                }
559            }
560        }
561    }
562
563    /// Check if HTTP status code is retryable
564    fn is_retryable_status(status_code: u16) -> bool {
565        match status_code {
566            // Rate limiting
567            429 => true,
568            // Request timeout
569            408 => true,
570            // Server errors are generally retryable
571            500..=599 => true,
572            _ => false,
573        }
574    }
575
576    /// Clean up expired logout tracking
577    pub fn cleanup_expired_logouts(&mut self) -> usize {
578        let now = SystemTime::now();
579        let initial_count = self.active_logouts.len();
580
581        self.active_logouts.retain(|_, timestamp| {
582            now.duration_since(*timestamp)
583                .map(|d| d.as_secs() < 3600) // Keep for 1 hour
584                .unwrap_or(false)
585        });
586
587        initial_count - self.active_logouts.len()
588    }
589
590    /// Get discovery metadata for back-channel logout
591    pub fn get_discovery_metadata(&self) -> HashMap<String, serde_json::Value> {
592        let mut metadata = HashMap::new();
593
594        if self.config.enabled {
595            metadata.insert(
596                "backchannel_logout_supported".to_string(),
597                serde_json::Value::Bool(true),
598            );
599
600            metadata.insert(
601                "backchannel_logout_session_supported".to_string(),
602                serde_json::Value::Bool(self.config.include_session_claims),
603            );
604        }
605
606        metadata
607    }
608}
609
610// Helper function to deserialize serde_json::Value to LogoutEvents
611fn serde_from_value<T>(value: serde_json::Value) -> Result<T>
612where
613    T: serde::de::DeserializeOwned,
614{
615    serde_json::from_value(value)
616        .map_err(|e| AuthError::internal(format!("JSON deserialization error: {}", e)))
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use crate::server::oidc::oidc_session_management::SessionManagementConfig;
623
624    fn create_test_manager() -> Result<BackChannelLogoutManager> {
625        let config = BackChannelLogoutConfig::default();
626        let session_manager = SessionManager::new(SessionManagementConfig::default());
627        BackChannelLogoutManager::new(config, session_manager)
628    }
629
630    #[test]
631    fn test_retryable_status_codes() {
632        // Server errors should be retryable
633        assert!(BackChannelLogoutManager::is_retryable_status(500));
634        assert!(BackChannelLogoutManager::is_retryable_status(502));
635        assert!(BackChannelLogoutManager::is_retryable_status(503));
636
637        // Rate limiting should be retryable
638        assert!(BackChannelLogoutManager::is_retryable_status(429));
639
640        // Client errors should not be retryable
641        assert!(!BackChannelLogoutManager::is_retryable_status(400));
642        assert!(!BackChannelLogoutManager::is_retryable_status(401));
643        assert!(!BackChannelLogoutManager::is_retryable_status(404));
644
645        // Success should not be retryable (already succeeded)
646        assert!(!BackChannelLogoutManager::is_retryable_status(200));
647        assert!(!BackChannelLogoutManager::is_retryable_status(204));
648    }
649
650    #[test]
651    fn test_logout_token_generation() -> Result<()> {
652        let manager = create_test_manager()?;
653
654        let request = BackChannelLogoutRequest {
655            session_id: "session123".to_string(),
656            sub: "user123".to_string(),
657            sid: Some("sid123".to_string()),
658            iss: "https://op.example.com".to_string(),
659            initiating_client_id: None,
660            additional_events: None,
661        };
662
663        let token = manager.generate_logout_token(&request, "jti123")?;
664
665        assert!(!token.is_empty());
666        // Token should be a valid JWT format (3 base64 parts separated by dots)
667        assert_eq!(token.split('.').count(), 3);
668
669        Ok(())
670    }
671
672    #[test]
673    fn test_logout_token_with_additional_events() -> Result<()> {
674        let manager = create_test_manager()?;
675
676        // Create additional events to test the serde_from_value helper function
677        let mut additional_events = HashMap::new();
678        additional_events.insert(
679            "http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"
680                .to_string(),
681            serde_json::json!({
682                "reason": "password_change",
683                "timestamp": "2025-08-07T12:00:00Z"
684            }),
685        );
686        additional_events.insert(
687            "custom-event-type".to_string(),
688            serde_json::json!({
689                "custom_field": "custom_value"
690            }),
691        );
692
693        let request = BackChannelLogoutRequest {
694            session_id: "session123".to_string(),
695            sub: "user123".to_string(),
696            sid: Some("sid123".to_string()),
697            iss: "https://op.example.com".to_string(),
698            initiating_client_id: Some("client_456".to_string()),
699            additional_events: Some(additional_events),
700        };
701
702        let token = manager.generate_logout_token(&request, "jti456")?;
703
704        assert!(!token.is_empty());
705        // Token should be a valid JWT format (3 base64 parts separated by dots)
706        assert_eq!(token.split('.').count(), 3);
707
708        // Decode and verify the token contains our additional events
709        use base64::Engine as _;
710        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
711
712        let parts: Vec<&str> = token.split('.').collect();
713        assert_eq!(parts.len(), 3);
714
715        // Decode the claims (payload) part
716        let claims_json = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
717        let claims: serde_json::Value = serde_json::from_str(&claims_json).unwrap();
718
719        // Verify the events contain both standard and additional events
720        let events = &claims["events"];
721        assert!(events["http://schemas.openid.net/secevent/oauth/event-type/logout"].is_object());
722        assert!(events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"].is_object());
723        assert!(events["custom-event-type"].is_object());
724
725        // Verify additional event data was properly processed by serde_from_value
726        assert_eq!(
727            events["http://schemas.openid.net/secevent/risc/event-type/account-credential-change-required"]
728                ["reason"],
729            "password_change"
730        );
731        assert_eq!(events["custom-event-type"]["custom_field"], "custom_value");
732
733        Ok(())
734    }
735
736    #[test]
737    fn test_discovery_metadata() -> Result<()> {
738        let manager = create_test_manager()?;
739        let metadata = manager.get_discovery_metadata();
740
741        assert_eq!(
742            metadata.get("backchannel_logout_supported"),
743            Some(&serde_json::Value::Bool(true))
744        );
745        assert_eq!(
746            metadata.get("backchannel_logout_session_supported"),
747            Some(&serde_json::Value::Bool(true))
748        );
749
750        Ok(())
751    }
752}