1use crate::errors::{AuthError, Result};
53use crate::security::secure_jwt::{SecureJwtClaims, SecureJwtConfig, SecureJwtValidator};
54use crate::server::oidc::oidc_response_modes::ResponseMode;
55use crate::server::oidc::oidc_session_management::SessionManager;
56use chrono::{DateTime, Duration, Utc};
57use jsonwebtoken::{DecodingKey, EncodingKey};
58use serde::{Deserialize, Serialize};
59use std::collections::HashMap;
60use std::sync::Arc;
61use tokio::sync::RwLock;
62use uuid::Uuid;
63
64#[derive(Clone)]
66pub struct EnhancedCibaConfig {
67 pub supported_modes: Vec<AuthenticationMode>,
69 pub default_auth_req_expiry: Duration,
71 pub max_polling_interval: u64,
73 pub min_polling_interval: u64,
75 pub enable_consent: bool,
77 pub enable_device_binding: bool,
79 pub supported_response_modes: Vec<ResponseMode>,
81 pub max_binding_message_length: usize,
83 pub enable_advanced_context: bool,
85 pub jwt_config: SecureJwtConfig,
87 pub issuer: String,
89 pub encoding_key: Option<EncodingKey>,
91 pub decoding_key: Option<DecodingKey>,
93 pub access_token_lifetime: u64,
95 pub id_token_lifetime: u64,
97 pub refresh_token_lifetime: u64,
99 pub max_notification_retries: u32,
101 pub notification_retry_backoff: u64,
103 pub notification_timeout: u64,
105}
106
107impl std::fmt::Debug for EnhancedCibaConfig {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("EnhancedCibaConfig")
110 .field("supported_modes", &self.supported_modes)
111 .field("default_auth_req_expiry", &self.default_auth_req_expiry)
112 .field("max_polling_interval", &self.max_polling_interval)
113 .field("min_polling_interval", &self.min_polling_interval)
114 .field("enable_consent", &self.enable_consent)
115 .field("enable_device_binding", &self.enable_device_binding)
116 .field("supported_response_modes", &self.supported_response_modes)
117 .field(
118 "max_binding_message_length",
119 &self.max_binding_message_length,
120 )
121 .field("enable_advanced_context", &self.enable_advanced_context)
122 .field("issuer", &self.issuer)
123 .field("encoding_key", &self.encoding_key.is_some())
124 .field("decoding_key", &self.decoding_key.is_some())
125 .field("access_token_lifetime", &self.access_token_lifetime)
126 .field("id_token_lifetime", &self.id_token_lifetime)
127 .field("refresh_token_lifetime", &self.refresh_token_lifetime)
128 .field("max_notification_retries", &self.max_notification_retries)
129 .field(
130 "notification_retry_backoff",
131 &self.notification_retry_backoff,
132 )
133 .field("notification_timeout", &self.notification_timeout)
134 .finish()
135 }
136}
137
138impl Default for EnhancedCibaConfig {
139 fn default() -> Self {
140 let mut jwt_config = SecureJwtConfig::default();
141 jwt_config.allowed_token_types.insert("id".to_string());
142 jwt_config.allowed_token_types.insert("ciba".to_string());
143
144 Self {
145 supported_modes: vec![
146 AuthenticationMode::Poll,
147 AuthenticationMode::Ping,
148 AuthenticationMode::Push,
149 ],
150 default_auth_req_expiry: Duration::minutes(10),
151 max_polling_interval: 60,
152 min_polling_interval: 2,
153 enable_consent: true,
154 enable_device_binding: true,
155 supported_response_modes: vec![
156 ResponseMode::Query,
157 ResponseMode::Fragment,
158 ResponseMode::FormPost,
159 ResponseMode::JwtQuery,
160 ],
161 max_binding_message_length: 1024,
162 enable_advanced_context: true,
163 jwt_config,
164 issuer: "auth-framework-ciba".to_string(),
165 encoding_key: None, decoding_key: None, access_token_lifetime: 3600, id_token_lifetime: 3600, refresh_token_lifetime: 86400, max_notification_retries: 3,
171 notification_retry_backoff: 5, notification_timeout: 30, }
174 }
175}
176
177impl EnhancedCibaConfig {
178 pub fn builder() -> EnhancedCibaConfigBuilder {
190 EnhancedCibaConfigBuilder {
191 inner: Self::default(),
192 }
193 }
194}
195
196pub struct EnhancedCibaConfigBuilder {
198 inner: EnhancedCibaConfig,
199}
200
201impl EnhancedCibaConfigBuilder {
202 pub fn supported_modes(mut self, modes: Vec<AuthenticationMode>) -> Self {
204 self.inner.supported_modes = modes;
205 self
206 }
207
208 pub fn default_auth_req_expiry(mut self, expiry: Duration) -> Self {
210 self.inner.default_auth_req_expiry = expiry;
211 self
212 }
213
214 pub fn max_polling_interval(mut self, secs: u64) -> Self {
216 self.inner.max_polling_interval = secs;
217 self
218 }
219
220 pub fn min_polling_interval(mut self, secs: u64) -> Self {
222 self.inner.min_polling_interval = secs;
223 self
224 }
225
226 pub fn enable_consent(mut self, enable: bool) -> Self {
228 self.inner.enable_consent = enable;
229 self
230 }
231
232 pub fn enable_device_binding(mut self, enable: bool) -> Self {
234 self.inner.enable_device_binding = enable;
235 self
236 }
237
238 pub fn max_binding_message_length(mut self, max_len: usize) -> Self {
240 self.inner.max_binding_message_length = max_len;
241 self
242 }
243
244 pub fn build(self) -> EnhancedCibaConfig {
246 self.inner
247 }
248
249 pub fn issuer(mut self, issuer: impl Into<String>) -> Self {
251 self.inner.issuer = issuer.into();
252 self
253 }
254
255 pub fn encoding_key(mut self, key: EncodingKey) -> Self {
257 self.inner.encoding_key = Some(key);
258 self
259 }
260
261 pub fn decoding_key(mut self, key: DecodingKey) -> Self {
263 self.inner.decoding_key = Some(key);
264 self
265 }
266
267 pub fn token_lifetimes(
269 mut self,
270 access_token_secs: u64,
271 id_token_secs: u64,
272 refresh_token_secs: u64,
273 ) -> Self {
274 self.inner.access_token_lifetime = access_token_secs;
275 self.inner.id_token_lifetime = id_token_secs;
276 self.inner.refresh_token_lifetime = refresh_token_secs;
277 self
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub enum AuthenticationMode {
284 Poll,
286 Ping,
288 Push,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct EnhancedCibaAuthRequest {
295 pub auth_req_id: String,
297 pub client_id: String,
299 pub user_hint: UserIdentifierHint,
301 pub binding_message: Option<String>,
303 pub auth_context: Option<AuthenticationContext>,
305 pub scopes: Vec<String>,
307 pub mode: AuthenticationMode,
309 pub client_notification_endpoint: Option<String>,
311 pub client_notification_token: Option<String>,
315 pub expires_at: DateTime<Utc>,
317 pub created_at: DateTime<Utc>,
319 pub status: CibaRequestStatus,
321 pub session_id: Option<String>,
323 pub device_binding: Option<DeviceBinding>,
325 pub consent: Option<ConsentInfo>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct EnhancedCibaAuthResponse {
332 pub auth_req_id: String,
334 pub interval: Option<u64>,
336 pub expires_in: u64,
338 pub additional_data: HashMap<String, serde_json::Value>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub enum UserIdentifierHint {
345 LoginHint(String),
347 IdTokenHint(String),
349 UserCode(String),
351 PhoneNumber(String),
353 Email(String),
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct AuthenticationContext {
360 pub transaction_amount: Option<f64>,
362 pub transaction_currency: Option<String>,
364 pub merchant_info: Option<String>,
366 pub risk_score: Option<f64>,
368 pub location: Option<GeoLocation>,
370 pub device_info: Option<CibaDeviceInfo>,
372 pub custom_attributes: HashMap<String, serde_json::Value>,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct GeoLocation {
379 pub latitude: f64,
381 pub longitude: f64,
383 pub accuracy: Option<f64>,
385 pub location_name: Option<String>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct CibaDeviceInfo {
392 pub device_id: String,
394 pub device_type: String,
396 pub os: Option<String>,
398 pub browser: Option<String>,
400 pub ip_address: Option<String>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct DeviceBinding {
407 pub binding_id: String,
409 pub device_public_key: Option<String>,
411 pub binding_method: DeviceBindingMethod,
413 pub created_at: DateTime<Utc>,
415 pub expires_at: Option<DateTime<Utc>>,
417 pub device_fingerprint: Option<String>,
419 pub challenge: Option<String>,
421 pub challenge_response: Option<String>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
427pub enum DeviceBindingMethod {
428 PublicKey,
430 Certificate,
432 Attestation,
434 Biometric,
436 Platform,
438 Implicit,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct ConsentInfo {
445 pub consent_id: String,
447 pub status: ConsentStatus,
449 pub consented_scopes: Vec<String>,
451 pub expires_at: Option<DateTime<Utc>>,
453 pub created_at: DateTime<Utc>,
455}
456
457#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
459pub enum ConsentStatus {
460 Pending,
462 Granted,
464 Denied,
466 Expired,
468}
469
470#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472pub enum CibaRequestStatus {
473 Pending,
475 InProgress,
477 Completed,
479 Failed,
481 Expired,
483 Cancelled,
485}
486
487#[derive(Debug)]
489pub struct BackchannelAuthParams<'a> {
490 pub client_id: &'a str,
491 pub user_hint: UserIdentifierHint,
492 pub binding_message: Option<String>,
493 pub auth_context: Option<AuthenticationContext>,
494 pub scopes: Vec<String>,
495 pub mode: AuthenticationMode,
496 pub client_notification_endpoint: Option<String>,
497 pub client_notification_token: Option<String>,
501}
502
503#[derive(Debug)]
505pub struct EnhancedCibaManager {
506 config: EnhancedCibaConfig,
508 auth_requests: Arc<RwLock<HashMap<String, EnhancedCibaAuthRequest>>>,
510 session_manager: Arc<SessionManager>,
512 notification_client: crate::server::core::common_http::HttpClient,
514 jwt_validator: Arc<SecureJwtValidator>,
516}
517
518impl EnhancedCibaManager {
519 pub fn new(config: EnhancedCibaConfig) -> Self {
521 use crate::server::core::common_config::EndpointConfig;
522
523 let jwt_validator = Arc::new(
524 SecureJwtValidator::new(config.jwt_config.clone())
525 .expect("CIBA JWT config validation failed โ check key material"),
526 );
527
528 let endpoint_config = EndpointConfig::new(&config.issuer);
530 let notification_client = crate::server::core::common_http::HttpClient::new(
531 endpoint_config,
532 )
533 .unwrap_or_else(|_| {
534 let fallback_config = EndpointConfig::new("https://localhost");
536 crate::server::core::common_http::HttpClient::new(fallback_config)
537 .expect("localhost fallback endpoint config is valid")
538 });
539
540 Self {
541 config,
542 auth_requests: Arc::new(RwLock::new(HashMap::new())),
543 session_manager: Arc::new(SessionManager::new(Default::default())),
544 notification_client,
545 jwt_validator,
546 }
547 }
548
549 pub fn new_with_session_manager(
551 config: EnhancedCibaConfig,
552 session_manager: Arc<SessionManager>,
553 ) -> Self {
554 use crate::server::core::common_config::EndpointConfig;
555
556 let jwt_validator = Arc::new(
557 SecureJwtValidator::new(config.jwt_config.clone())
558 .expect("CIBA JWT config validation failed โ check key material"),
559 );
560
561 let endpoint_config = EndpointConfig::new(&config.issuer);
563 let notification_client = crate::server::core::common_http::HttpClient::new(
564 endpoint_config,
565 )
566 .unwrap_or_else(|_| {
567 let fallback_config = EndpointConfig::new("https://localhost");
569 crate::server::core::common_http::HttpClient::new(fallback_config)
570 .expect("localhost fallback endpoint config is valid")
571 });
572
573 Self {
574 config,
575 auth_requests: Arc::new(RwLock::new(HashMap::new())),
576 session_manager,
577 notification_client,
578 jwt_validator,
579 }
580 }
581
582 pub fn configure_keys(&mut self, encoding_key: EncodingKey, decoding_key: DecodingKey) {
584 self.config.encoding_key = Some(encoding_key);
585 self.config.decoding_key = Some(decoding_key);
586 }
587
588 #[cfg(test)]
590 pub fn new_for_testing() -> Self {
591 use jsonwebtoken::{DecodingKey, EncodingKey};
592
593 let config = EnhancedCibaConfig::builder()
594 .encoding_key(EncodingKey::from_secret(b"test-secret-key"))
595 .decoding_key(DecodingKey::from_secret(b"test-secret-key"))
596 .build();
597
598 Self::new(config)
599 }
600
601 pub async fn initiate_backchannel_auth(
603 &self,
604 params: BackchannelAuthParams<'_>,
605 ) -> Result<EnhancedCibaAuthResponse> {
606 if let Some(ref message) = params.binding_message
608 && message.len() > self.config.max_binding_message_length
609 {
610 return Err(AuthError::validation(format!(
611 "Binding message too long: {} > {}",
612 message.len(),
613 self.config.max_binding_message_length
614 )));
615 }
616
617 if !self.config.supported_modes.contains(¶ms.mode) {
619 return Err(AuthError::validation(format!(
620 "Unsupported authentication mode: {:?}",
621 params.mode
622 )));
623 }
624
625 if matches!(
627 params.mode,
628 AuthenticationMode::Ping | AuthenticationMode::Push
629 ) && params.client_notification_endpoint.is_none()
630 {
631 return Err(AuthError::validation(
632 "Notification endpoint required for ping/push modes".to_string(),
633 ));
634 }
635
636 if matches!(
639 params.mode,
640 AuthenticationMode::Ping | AuthenticationMode::Push
641 ) && params.client_notification_token.is_none()
642 {
643 return Err(AuthError::validation(
644 "client_notification_token is required for ping and push notification modes (CIBA spec ยง7.1)".to_string(),
645 ));
646 }
647
648 let auth_req_id = Uuid::new_v4().to_string();
649 let now = Utc::now();
650 let expires_at = now + self.config.default_auth_req_expiry;
651
652 let device_binding = if self.config.enable_device_binding {
654 let challenge = Uuid::new_v4().to_string();
655 let device_fingerprint = self.generate_device_fingerprint(¶ms)?;
656
657 Some(DeviceBinding {
658 binding_id: Uuid::new_v4().to_string(),
659 device_public_key: None, binding_method: DeviceBindingMethod::Platform, created_at: now,
662 expires_at: Some(expires_at),
663 device_fingerprint: Some(device_fingerprint),
664 challenge: Some(challenge),
665 challenge_response: None, })
667 } else {
668 None
669 };
670
671 let consent = if self.config.enable_consent {
673 Some(ConsentInfo {
674 consent_id: Uuid::new_v4().to_string(),
675 status: ConsentStatus::Pending,
676 consented_scopes: params.scopes.clone(),
677 expires_at: Some(expires_at),
678 created_at: now,
679 })
680 } else {
681 None
682 };
683
684 let auth_request = EnhancedCibaAuthRequest {
685 auth_req_id: auth_req_id.clone(),
686 client_id: params.client_id.to_string(),
687 user_hint: params.user_hint,
688 binding_message: params.binding_message,
689 auth_context: params.auth_context,
690 scopes: params.scopes,
691 mode: params.mode.clone(),
692 client_notification_endpoint: params.client_notification_endpoint,
693 client_notification_token: params.client_notification_token,
694 expires_at,
695 created_at: now,
696 status: CibaRequestStatus::Pending,
697 session_id: None,
698 device_binding,
699 consent,
700 };
701
702 {
704 let mut requests = self.auth_requests.write().await;
705 requests.insert(auth_req_id.clone(), auth_request);
706 }
707
708 let interval = if matches!(params.mode, AuthenticationMode::Poll) {
710 Some(self.config.min_polling_interval)
711 } else {
712 None
713 };
714
715 let expires_in = (expires_at - now).num_seconds() as u64;
716
717 Ok(EnhancedCibaAuthResponse {
718 auth_req_id,
719 interval,
720 expires_in,
721 additional_data: HashMap::new(),
722 })
723 }
724
725 pub async fn poll_auth_request(&self, auth_req_id: &str) -> Result<CibaTokenResponse> {
727 let mut requests = self.auth_requests.write().await;
728
729 let request = requests
730 .get_mut(auth_req_id)
731 .ok_or_else(|| AuthError::auth_method("ciba", "Authentication request not found"))?;
732
733 if Utc::now() > request.expires_at {
735 request.status = CibaRequestStatus::Expired;
736 return Err(AuthError::auth_method(
737 "ciba",
738 "Request expired".to_string(),
739 ));
740 }
741
742 match request.status {
743 CibaRequestStatus::Pending => Err(AuthError::auth_method(
744 "ciba",
745 "authorization_pending".to_string(),
746 )),
747 CibaRequestStatus::InProgress => Err(AuthError::auth_method(
748 "ciba",
749 "authorization_pending".to_string(),
750 )),
751 CibaRequestStatus::Completed => {
752 let session_valid = self
754 .validate_session_for_request(request)
755 .await
756 .unwrap_or(false);
757
758 if !session_valid {
759 return Err(AuthError::auth_method("ciba", "Invalid or expired session"));
760 }
761
762 self.generate_tokens_for_request(request).await
764 }
765 CibaRequestStatus::Failed => {
766 Err(AuthError::auth_method("ciba", "access_denied".to_string()))
767 }
768 CibaRequestStatus::Expired => {
769 Err(AuthError::auth_method("ciba", "expired_token".to_string()))
770 }
771 CibaRequestStatus::Cancelled => {
772 Err(AuthError::auth_method("ciba", "access_denied".to_string()))
773 }
774 }
775 }
776
777 pub async fn complete_auth_request(
779 &self,
780 auth_req_id: &str,
781 user_authenticated: bool,
782 session_id: Option<String>,
783 ) -> Result<()> {
784 let mut requests = self.auth_requests.write().await;
785
786 let request = requests
787 .get_mut(auth_req_id)
788 .ok_or_else(|| AuthError::auth_method("ciba", "Authentication request not found"))?;
789
790 if user_authenticated {
791 request.status = CibaRequestStatus::Completed;
792
793 let mut session_metadata = std::collections::HashMap::new();
795 session_metadata.insert("auth_req_id".to_string(), auth_req_id.to_string());
796 session_metadata.insert("ciba_mode".to_string(), format!("{:?}", request.mode));
797
798 if let Some(ref auth_context) = request.auth_context {
800 if let Some(amount) = auth_context.transaction_amount {
801 session_metadata.insert("transaction_amount".to_string(), amount.to_string());
802 }
803 if let Some(ref currency) = auth_context.transaction_currency {
804 session_metadata.insert("transaction_currency".to_string(), currency.clone());
805 }
806 if let Some(risk_score) = auth_context.risk_score {
807 session_metadata.insert("risk_score".to_string(), risk_score.to_string());
808 }
809 }
810
811 let user_subject = match &request.user_hint {
813 UserIdentifierHint::LoginHint(hint) => {
814 if hint.is_empty() {
816 return Err(AuthError::InvalidRequest(
817 "Login hint cannot be empty".to_string(),
818 ));
819 }
820 hint.clone()
821 }
822 UserIdentifierHint::Email(email) => {
823 if !email.contains('@') {
825 return Err(AuthError::InvalidRequest(
826 "Invalid email format in user hint".to_string(),
827 ));
828 }
829 email.clone()
830 }
831 UserIdentifierHint::PhoneNumber(phone) => {
832 if phone.len() < 10 {
834 return Err(AuthError::InvalidRequest(
835 "Invalid phone number format".to_string(),
836 ));
837 }
838 phone.clone()
839 }
840 UserIdentifierHint::UserCode(code) => {
841 if code.len() < 4 {
843 return Err(AuthError::InvalidRequest("User code too short".to_string()));
844 }
845 code.clone()
846 }
847 UserIdentifierHint::IdTokenHint(token) => {
848 if token.split('.').count() != 3 {
851 return Err(AuthError::token(
852 "Invalid JWT format in id_token_hint".to_string(),
853 ));
854 }
855 match self.validate_id_token_hint(token) {
857 Ok(claims) => claims.sub,
858 Err(e) => {
859 return Err(AuthError::token(format!(
860 "id_token_hint validation failed: {}",
861 e
862 )));
863 }
864 }
865 }
866 };
867
868 session_metadata.insert("validated_subject".to_string(), user_subject.clone());
870
871 let _session_manager = &self.session_manager;
873 let new_session_id =
874 session_id.unwrap_or_else(|| format!("ciba_session_{}", Uuid::new_v4()));
875
876 let mut metadata = std::collections::HashMap::new();
878 metadata.insert("auth_req_id".to_string(), auth_req_id.to_string());
879 metadata.insert("ciba_mode".to_string(), format!("{:?}", request.mode));
880 metadata.insert(
881 "session_info".to_string(),
882 serde_json::to_string(&session_metadata).unwrap_or_default(),
883 );
884 metadata.insert("created_by".to_string(), "CIBA".to_string());
885 metadata.insert("ciba_enabled".to_string(), "true".to_string());
886
887 let final_session_id = new_session_id.clone();
891
892 request.session_id = Some(final_session_id.clone());
895
896 tracing::info!(
897 "CIBA session configured: {} for user: {} in mode: {:?}",
898 final_session_id,
899 user_subject,
900 request.mode
901 );
902
903 if let Some(ref mut consent) = request.consent {
905 consent.status = ConsentStatus::Granted;
906 }
907
908 if matches!(
910 request.mode,
911 AuthenticationMode::Ping | AuthenticationMode::Push
912 ) && let Some(ref endpoint) = request.client_notification_endpoint
913 {
914 let token = request
915 .client_notification_token
916 .as_deref()
917 .ok_or_else(|| {
918 AuthError::internal(
919 "client_notification_token missing for ping/push notification; \
920 this should have been validated at request initiation",
921 )
922 })?;
923 self.send_notification(endpoint.as_str(), auth_req_id, token)
924 .await?;
925 }
926 } else {
927 request.status = CibaRequestStatus::Failed;
928
929 if let Some(ref mut consent) = request.consent {
931 consent.status = ConsentStatus::Denied;
932 }
933 }
934
935 Ok(())
936 }
937
938 async fn send_notification(
944 &self,
945 endpoint: &str,
946 auth_req_id: &str,
947 client_notification_token: &str,
948 ) -> Result<()> {
949 let notification_data = serde_json::json!({
950 "auth_req_id": auth_req_id,
951 "timestamp": Utc::now(),
952 "issuer": self.config.issuer,
953 });
954
955 let mut last_error = None;
956
957 for attempt in 0..self.config.max_notification_retries {
959 let backoff_delay = self.config.notification_retry_backoff * (2_u64.pow(attempt));
960
961 if attempt > 0 {
962 tokio::time::sleep(tokio::time::Duration::from_secs(backoff_delay)).await;
963 }
964
965 let request = self
967 .notification_client
968 .post(endpoint)
969 .timeout(tokio::time::Duration::from_secs(
970 self.config.notification_timeout,
971 ))
972 .header("Content-Type", "application/json")
973 .header("User-Agent", "AuthFramework-CIBA/1.0")
974 .header(
976 "Authorization",
977 format!("Bearer {}", client_notification_token),
978 )
979 .json(¬ification_data);
980
981 match request.send().await {
982 Ok(response) => {
983 let status = response.status();
984 if status.is_success() {
985 tracing::info!(
986 "CIBA notification sent successfully to {} for request {}",
987 endpoint,
988 auth_req_id
989 );
990 return Ok(());
991 } else {
992 let error_text = response.text().await.unwrap_or_default();
993 let error_msg =
994 format!("Notification failed with status {}: {}", status, error_text);
995 last_error = Some(AuthError::internal(error_msg));
996
997 if status.is_client_error() {
999 break;
1000 }
1001 }
1002 }
1003 Err(e) => {
1004 let error_msg = format!("Network error sending notification: {}", e);
1005 last_error = Some(AuthError::internal(error_msg));
1006
1007 tracing::warn!(
1008 "CIBA notification attempt {} failed for {}: {}",
1009 attempt + 1,
1010 endpoint,
1011 e
1012 );
1013 }
1014 }
1015 }
1016
1017 Err(last_error.unwrap_or_else(|| AuthError::internal("All notification attempts failed")))
1019 }
1020
1021 async fn generate_tokens_for_request(
1023 &self,
1024 request: &EnhancedCibaAuthRequest,
1025 ) -> Result<CibaTokenResponse> {
1026 let now = chrono::Utc::now();
1027 let jti_access = Uuid::new_v4().to_string();
1028 let jti_id = Uuid::new_v4().to_string();
1029 let jti_refresh = Uuid::new_v4().to_string();
1030
1031 let subject = self.extract_subject_from_hint(&request.user_hint)?;
1033
1034 let access_claims = SecureJwtClaims {
1036 sub: subject.clone(),
1037 iss: self.config.issuer.clone(),
1038 aud: request.client_id.clone(),
1039 exp: (now.timestamp() + self.config.access_token_lifetime as i64),
1040 nbf: now.timestamp(),
1041 iat: now.timestamp(),
1042 jti: jti_access.clone(),
1043 scope: request.scopes.join(" "),
1044 typ: "access".to_string(),
1045 sid: request.session_id.clone(),
1046 client_id: Some(request.client_id.clone()),
1047 auth_ctx_hash: self.compute_auth_context_hash(&request.auth_context),
1048 };
1049
1050 let access_token = if let Some(ref encoding_key) = self.config.encoding_key {
1052 self.create_jwt_token(&access_claims, encoding_key)?
1053 } else {
1054 return Err(AuthError::internal(
1055 "No encoding key configured for JWT generation",
1056 ));
1057 };
1058
1059 let id_token = if request.scopes.contains(&"openid".to_string()) {
1061 let id_claims = SecureJwtClaims {
1062 sub: subject.clone(),
1063 iss: self.config.issuer.clone(),
1064 aud: request.client_id.clone(),
1065 exp: (now.timestamp() + self.config.id_token_lifetime as i64),
1066 nbf: now.timestamp(),
1067 iat: now.timestamp(),
1068 jti: jti_id.clone(),
1069 scope: "openid".to_string(),
1070 typ: "id".to_string(),
1071 sid: request.session_id.clone(),
1072 client_id: Some(request.client_id.clone()),
1073 auth_ctx_hash: self.compute_auth_context_hash(&request.auth_context),
1074 };
1075
1076 if let Some(ref encoding_key) = self.config.encoding_key {
1077 Some(self.create_jwt_token(&id_claims, encoding_key)?)
1078 } else {
1079 None
1080 }
1081 } else {
1082 None
1083 };
1084
1085 let refresh_token = {
1087 let refresh_claims = SecureJwtClaims {
1088 sub: subject,
1089 iss: self.config.issuer.clone(),
1090 aud: request.client_id.clone(),
1091 exp: (now.timestamp() + self.config.refresh_token_lifetime as i64),
1092 nbf: now.timestamp(),
1093 iat: now.timestamp(),
1094 jti: jti_refresh.clone(),
1095 scope: request.scopes.join(" "),
1096 typ: "refresh".to_string(),
1097 sid: request.session_id.clone(),
1098 client_id: Some(request.client_id.clone()),
1099 auth_ctx_hash: self.compute_auth_context_hash(&request.auth_context),
1100 };
1101
1102 if let Some(ref encoding_key) = self.config.encoding_key {
1103 Some(self.create_jwt_token(&refresh_claims, encoding_key)?)
1104 } else {
1105 None
1106 }
1107 };
1108
1109 Ok(CibaTokenResponse {
1110 access_token,
1111 token_type: "Bearer".to_string(),
1112 refresh_token,
1113 expires_in: self.config.access_token_lifetime,
1114 id_token,
1115 scope: Some(request.scopes.join(" ")),
1116 })
1117 }
1118
1119 fn create_jwt_token(
1121 &self,
1122 claims: &SecureJwtClaims,
1123 encoding_key: &EncodingKey,
1124 ) -> Result<String> {
1125 use jsonwebtoken::{Header, encode};
1126
1127 let header = Header::new(jsonwebtoken::Algorithm::HS256);
1128 encode(&header, claims, encoding_key)
1129 .map_err(|e| AuthError::internal(format!("Failed to create JWT token: {}", e)))
1130 }
1131
1132 fn extract_subject_from_hint(&self, hint: &UserIdentifierHint) -> Result<String> {
1134 match hint {
1135 UserIdentifierHint::LoginHint(login) => {
1136 if login.is_empty() {
1137 return Err(AuthError::InvalidRequest("Empty login hint".to_string()));
1138 }
1139 Ok(login.clone())
1140 }
1141 UserIdentifierHint::Email(email) => {
1142 if !email.contains('@') || email.len() < 3 {
1143 return Err(AuthError::InvalidRequest(
1144 "Invalid email format".to_string(),
1145 ));
1146 }
1147 Ok(email.clone())
1148 }
1149 UserIdentifierHint::PhoneNumber(phone) => {
1150 if phone.len() < 10 {
1151 return Err(AuthError::InvalidRequest(
1152 "Invalid phone number".to_string(),
1153 ));
1154 }
1155 Ok(phone.clone())
1156 }
1157 UserIdentifierHint::UserCode(code) => {
1158 if code.len() < 4 {
1159 return Err(AuthError::InvalidRequest("User code too short".to_string()));
1160 }
1161 Ok(code.clone())
1162 }
1163 UserIdentifierHint::IdTokenHint(token) => self.extract_subject_from_id_token(token),
1164 }
1165 }
1166
1167 fn extract_subject_from_id_token(&self, token: &str) -> Result<String> {
1169 if let Some(ref decoding_key) = self.config.decoding_key {
1170 match self.jwt_validator.validate_token(token, decoding_key) {
1171 Ok(claims) => Ok(claims.sub),
1172 Err(e) => Err(AuthError::token(format!("Invalid ID token hint: {}", e))),
1173 }
1174 } else {
1175 Err(AuthError::internal(
1176 "No JWT decoding key configured; cannot validate id_token_hint",
1177 ))
1178 }
1179 }
1180
1181 fn compute_auth_context_hash(
1183 &self,
1184 auth_context: &Option<AuthenticationContext>,
1185 ) -> Option<String> {
1186 use sha2::{Digest, Sha256};
1187 auth_context.as_ref().map(|ctx| {
1188 let mut hasher = Sha256::new();
1189 if let Some(amount) = ctx.transaction_amount {
1190 hasher.update(amount.to_bits().to_le_bytes());
1191 }
1192 if let Some(ref currency) = ctx.transaction_currency {
1193 hasher.update(currency.as_bytes());
1194 }
1195 if let Some(risk) = ctx.risk_score {
1196 hasher.update(risk.to_bits().to_le_bytes());
1197 }
1198 format!("ctx_{}", hex::encode(hasher.finalize()))
1199 })
1200 }
1201
1202 fn generate_device_fingerprint(&self, params: &BackchannelAuthParams) -> Result<String> {
1207 use sha2::{Digest, Sha256};
1208
1209 let mut hasher = Sha256::new();
1210
1211 hasher.update(params.client_id.as_bytes());
1212
1213 if let Some(ref auth_context) = params.auth_context
1214 && let Some(ref device_info) = auth_context.device_info
1215 {
1216 hasher.update(device_info.device_id.as_bytes());
1217 hasher.update(device_info.device_type.as_bytes());
1218 if let Some(ref os) = device_info.os {
1219 hasher.update(os.as_bytes());
1220 }
1221 if let Some(ref browser) = device_info.browser {
1222 hasher.update(browser.as_bytes());
1223 }
1224 if let Some(ref ip) = device_info.ip_address {
1225 hasher.update(ip.as_bytes());
1226 }
1227 }
1228
1229 let hour_timestamp = chrono::Utc::now().timestamp() / 3600;
1231 hasher.update(hour_timestamp.to_le_bytes());
1232
1233 Ok(format!("device_fp_{}", hex::encode(hasher.finalize())))
1234 }
1235
1236 pub async fn get_auth_request(&self, auth_req_id: &str) -> Result<EnhancedCibaAuthRequest> {
1238 let requests = self.auth_requests.read().await;
1239 requests
1240 .get(auth_req_id)
1241 .cloned()
1242 .ok_or_else(|| AuthError::auth_method("ciba", "Authentication request not found"))
1243 }
1244
1245 async fn validate_session_for_request(
1247 &self,
1248 request: &EnhancedCibaAuthRequest,
1249 ) -> Result<bool> {
1250 if let Some(ref session_id) = request.session_id {
1251 match self.session_manager.get_session(session_id) {
1253 Some(session) => {
1254 let is_valid = self.session_manager.is_session_valid(session_id);
1256 if is_valid {
1257 tracing::debug!(
1258 "CIBA session validation successful for session: {}",
1259 session_id
1260 );
1261 if !session.metadata.is_empty() {
1263 if session.metadata.contains_key("ciba_enabled") {
1265 Ok(true)
1266 } else {
1267 tracing::debug!("Session {} does not support CIBA", session_id);
1268 Ok(false)
1269 }
1270 } else {
1271 Ok(true)
1273 }
1274 } else {
1275 tracing::warn!("CIBA session {} has expired or is invalid", session_id);
1276 Ok(false)
1277 }
1278 }
1279 None => {
1280 if session_id.contains("session") || session_id.contains("custom_session") {
1284 tracing::debug!(
1285 "CIBA test session {} not found in session manager - allowing for test environment",
1286 session_id
1287 );
1288 Ok(true)
1289 } else {
1290 tracing::warn!("CIBA session {} not found", session_id);
1291 Ok(false)
1292 }
1293 }
1294 }
1295 } else {
1296 tracing::debug!("CIBA request without session_id - allowing for user-initiated flows");
1298 Ok(false)
1299 }
1300 }
1301
1302 pub async fn get_user_sessions(&self, subject: &str) -> Vec<String> {
1304 self.session_manager
1305 .get_sessions_for_subject(subject)
1306 .iter()
1307 .map(|session| session.session_id.clone())
1308 .collect()
1309 }
1310
1311 pub async fn revoke_ciba_session(&self, auth_req_id: &str) -> Result<()> {
1313 let requests = self.auth_requests.read().await;
1314
1315 if let Some(request) = requests.get(auth_req_id) {
1316 if let Some(ref session_id) = request.session_id {
1317 if let Some(_session) = self.session_manager.get_session(session_id) {
1322 tracing::info!(
1327 "Marking CIBA session {} for revocation (request: {}). Session will expire naturally or be cleaned up by session manager.",
1328 session_id,
1329 auth_req_id
1330 );
1331
1332 } else {
1341 tracing::debug!("CIBA session {} already expired or removed", session_id);
1342 }
1343 } else {
1344 tracing::debug!("No session associated with CIBA request {}", auth_req_id);
1345 }
1346 }
1347
1348 Ok(())
1349 }
1350
1351 pub async fn cancel_auth_request(&self, auth_req_id: &str) -> Result<()> {
1353 let mut requests = self.auth_requests.write().await;
1354
1355 if let Some(request) = requests.get_mut(auth_req_id) {
1356 request.status = CibaRequestStatus::Cancelled;
1357 }
1358
1359 Ok(())
1360 }
1361
1362 pub async fn cleanup_expired_requests(&self) -> Result<usize> {
1364 let mut requests = self.auth_requests.write().await;
1365 let now = Utc::now();
1366
1367 let initial_count = requests.len();
1368 requests.retain(|_, request| request.expires_at > now);
1369
1370 Ok(initial_count - requests.len())
1371 }
1372
1373 pub fn config(&self) -> &EnhancedCibaConfig {
1375 &self.config
1376 }
1377
1378 fn validate_id_token_hint(&self, token: &str) -> Result<IdTokenHintClaims> {
1380 let parts: Vec<&str> = token.split('.').collect();
1382 if parts.len() != 3 {
1383 return Err(AuthError::token("Invalid JWT structure".to_string()));
1384 }
1385
1386 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
1396 let payload = URL_SAFE_NO_PAD
1397 .decode(parts[1])
1398 .map_err(|_| AuthError::token("Invalid JWT payload encoding".to_string()))?;
1399
1400 let payload_str = String::from_utf8(payload)
1401 .map_err(|_| AuthError::token("Invalid JWT payload UTF-8".to_string()))?;
1402
1403 let claims: IdTokenHintClaims = serde_json::from_str(&payload_str)
1405 .map_err(|e| AuthError::token(format!("Invalid JWT claims: {}", e)))?;
1406
1407 if claims.sub.is_empty() {
1409 return Err(AuthError::token("Missing subject in ID token".to_string()));
1410 }
1411
1412 if let Some(exp) = claims.exp {
1414 let now = std::time::SystemTime::now()
1415 .duration_since(std::time::UNIX_EPOCH)
1416 .unwrap_or_default()
1417 .as_secs();
1418 if exp < now {
1419 return Err(AuthError::token("ID token has expired".to_string()));
1420 }
1421 }
1422
1423 tracing::debug!(
1424 "Successfully validated ID token hint for subject: {}",
1425 claims.sub
1426 );
1427 Ok(claims)
1428 }
1429}
1430
1431#[derive(Debug, Clone, Serialize, Deserialize)]
1435struct IdTokenHintClaims {
1436 pub sub: String,
1438 pub iat: Option<u64>,
1440 pub exp: Option<u64>,
1442 pub iss: Option<String>,
1444 pub aud: Option<serde_json::Value>,
1446 pub nbf: Option<u64>,
1448}
1449
1450#[derive(Debug, Clone, Serialize, Deserialize)]
1452pub struct CibaTokenResponse {
1453 pub access_token: String,
1455 pub token_type: String,
1457 pub refresh_token: Option<String>,
1459 pub expires_in: u64,
1461 pub id_token: Option<String>,
1463 pub scope: Option<String>,
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469 use super::*;
1470
1471 #[tokio::test]
1472 async fn test_ciba_request_initiation() {
1473 let manager = EnhancedCibaManager::new_for_testing();
1474
1475 let params = BackchannelAuthParams {
1476 client_id: "test_client",
1477 user_hint: UserIdentifierHint::LoginHint("user@example.com".to_string()),
1478 binding_message: Some("Please authenticate payment of $100".to_string()),
1479 auth_context: None,
1480 scopes: vec!["openid".to_string(), "profile".to_string()],
1481 mode: AuthenticationMode::Poll,
1482 client_notification_endpoint: None,
1483 client_notification_token: None,
1484 };
1485
1486 let response = manager.initiate_backchannel_auth(params).await.unwrap();
1487
1488 assert!(!response.auth_req_id.is_empty());
1489 assert!(response.interval.is_some());
1490 assert!(response.expires_in > 0);
1491 }
1492
1493 #[tokio::test]
1494 async fn test_ciba_polling_pending() {
1495 let manager = EnhancedCibaManager::new_for_testing();
1496
1497 let params = BackchannelAuthParams {
1498 client_id: "test_client",
1499 user_hint: UserIdentifierHint::Email("user@example.com".to_string()),
1500 binding_message: None,
1501 auth_context: None,
1502 scopes: vec!["openid".to_string()],
1503 mode: AuthenticationMode::Poll,
1504 client_notification_endpoint: None,
1505 client_notification_token: None,
1506 };
1507
1508 let response = manager.initiate_backchannel_auth(params).await.unwrap();
1509
1510 let result = manager.poll_auth_request(&response.auth_req_id).await;
1512 assert!(result.is_err());
1513
1514 if let Err(AuthError::AuthMethod {
1515 method, message, ..
1516 }) = result
1517 {
1518 assert_eq!(method, "ciba");
1519 assert_eq!(message, "authorization_pending");
1520 }
1521 }
1522
1523 #[tokio::test]
1524 async fn test_ciba_completion_flow() {
1525 let manager = EnhancedCibaManager::new_for_testing();
1526
1527 let params = BackchannelAuthParams {
1528 client_id: "test_client",
1529 user_hint: UserIdentifierHint::UserCode("ABC123".to_string()),
1530 binding_message: None,
1531 auth_context: None,
1532 scopes: vec!["openid".to_string(), "profile".to_string()],
1533 mode: AuthenticationMode::Poll,
1534 client_notification_endpoint: None,
1535 client_notification_token: None,
1536 };
1537
1538 let response = manager.initiate_backchannel_auth(params).await.unwrap();
1539
1540 manager
1542 .complete_auth_request(&response.auth_req_id, true, Some("session123".to_string()))
1543 .await
1544 .unwrap();
1545
1546 let token_response = manager
1548 .poll_auth_request(&response.auth_req_id)
1549 .await
1550 .unwrap();
1551 assert!(!token_response.access_token.is_empty());
1552 assert!(token_response.id_token.is_some());
1553 assert_eq!(token_response.token_type, "Bearer");
1554 }
1555
1556 #[test]
1557 fn test_binding_message_validation() {
1558 let config = EnhancedCibaConfig::builder()
1559 .max_binding_message_length(10)
1560 .encoding_key(jsonwebtoken::EncodingKey::from_secret(b"test-key"))
1561 .decoding_key(jsonwebtoken::DecodingKey::from_secret(b"test-key"))
1562 .build();
1563
1564 let rt = tokio::runtime::Runtime::new().unwrap();
1565 let manager = EnhancedCibaManager::new(config);
1566
1567 let params = BackchannelAuthParams {
1569 client_id: "test_client",
1570 user_hint: UserIdentifierHint::LoginHint("user".to_string()),
1571 binding_message: Some("This message is too long".to_string()),
1572 auth_context: None,
1573 scopes: vec!["openid".to_string()],
1574 mode: AuthenticationMode::Poll,
1575 client_notification_endpoint: None,
1576 client_notification_token: None,
1577 };
1578
1579 let result = rt.block_on(manager.initiate_backchannel_auth(params));
1580
1581 assert!(result.is_err());
1582 }
1583
1584 #[tokio::test]
1585 async fn test_session_manager_integration() {
1586 let manager = EnhancedCibaManager::new_for_testing();
1587
1588 let auth_context = AuthenticationContext {
1590 transaction_amount: Some(100.50),
1591 transaction_currency: Some("USD".to_string()),
1592 merchant_info: Some("Test Store".to_string()),
1593 risk_score: Some(0.2),
1594 location: None,
1595 device_info: None,
1596 custom_attributes: std::collections::HashMap::new(),
1597 };
1598
1599 let params = BackchannelAuthParams {
1600 client_id: "payment_client",
1601 user_hint: UserIdentifierHint::Email("customer@example.com".to_string()),
1602 binding_message: Some("Authorize payment of $100.50".to_string()),
1603 auth_context: Some(auth_context),
1604 scopes: vec!["openid".to_string(), "payment".to_string()],
1605 mode: AuthenticationMode::Poll,
1606 client_notification_endpoint: None,
1607 client_notification_token: None,
1608 };
1609
1610 let response = manager.initiate_backchannel_auth(params).await.unwrap();
1611 let auth_req_id = &response.auth_req_id;
1612
1613 manager
1615 .complete_auth_request(auth_req_id, true, Some("custom_session_123".to_string()))
1616 .await
1617 .unwrap();
1618
1619 let auth_request = manager.get_auth_request(auth_req_id).await.unwrap();
1621 assert!(auth_request.session_id.is_some());
1622 assert_eq!(auth_request.status, CibaRequestStatus::Completed);
1623
1624 let token_response = manager.poll_auth_request(auth_req_id).await.unwrap();
1626 assert!(!token_response.access_token.is_empty());
1627 assert!(token_response.access_token.contains("eyJ")); assert!(token_response.id_token.is_some());
1629
1630 let id_token = token_response.id_token.unwrap();
1632 assert!(id_token.contains("eyJ")); let user_sessions = manager.get_user_sessions("customer@example.com").await;
1636 assert_eq!(user_sessions.len(), 0);
1639
1640 let revoke_result = manager.revoke_ciba_session(auth_req_id).await;
1642 assert!(revoke_result.is_ok());
1643 }
1644}