1pub mod actions;
3
4pub use actions::SamlClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info};
11
12use crate::client::llm_budget::call_llm_for_client;
13use crate::client::saml::actions::{
14 SAML_CLIENT_CONNECTED_EVENT, SAML_CLIENT_RESPONSE_RECEIVED_EVENT,
15};
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::protocol::{Event, StartupParams};
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23
24enum Applied {
27 Ran(String),
29 Disconnect,
31}
32
33#[derive(Clone, Copy)]
38pub enum Dispatch {
39 Inline,
41 Deferred,
45}
46
47struct SamlConfig {
54 entity_id: String,
55 acs_url: String,
56 binding: String,
57}
58
59impl SamlConfig {
60 const DEFAULT_ENTITY_ID: &'static str = "urn:netget:sp";
62 const DEFAULT_ACS_URL: &'static str = "http://localhost:8080/saml/acs";
64
65 fn resolve(params: Option<&StartupParams>) -> Result<Self> {
66 let (entity_id, acs_url, binding) = match params {
67 Some(params) => (
68 params.get_optional_string("entity_id")?,
69 params.get_optional_string("acs_url")?,
70 params.get_optional_string("binding")?,
71 ),
72 None => (None, None, None),
73 };
74
75 let binding = match binding {
80 None => "redirect".to_string(),
81 Some(value) => match value.trim().to_ascii_lowercase().as_str() {
82 "redirect" => "redirect".to_string(),
83 "post" => "post".to_string(),
84 _ => {
85 return Err(anyhow::anyhow!(
86 "startup parameter 'binding' must be \"redirect\" or \"post\", got {:?}",
87 value
88 ))
89 }
90 },
91 };
92
93 Ok(Self {
94 entity_id: entity_id.unwrap_or_else(|| Self::DEFAULT_ENTITY_ID.to_string()),
95 acs_url: acs_url.unwrap_or_else(|| Self::DEFAULT_ACS_URL.to_string()),
96 binding,
97 })
98 }
99}
100
101pub struct SamlClient;
103
104impl SamlClient {
105 pub async fn connect_with_llm_actions(
107 remote_addr: String,
108 llm_client: OllamaClient,
109 app_state: Arc<AppState>,
110 status_tx: mpsc::UnboundedSender<String>,
111 client_id: ClientId,
112 startup_params: Option<StartupParams>,
113 ) -> Result<SocketAddr> {
114 info!(
118 "SAML client {} initialized for IdP: {}",
119 client_id, remote_addr
120 );
121
122 let config = SamlConfig::resolve(startup_params.as_ref())?;
130
131 app_state
134 .with_client_mut(client_id, |client| {
135 client.set_protocol_field(
136 "saml_client".to_string(),
137 serde_json::json!("initialized"),
138 );
139 client.set_protocol_field("idp_url".to_string(), serde_json::json!(remote_addr));
140 client.set_protocol_field(
141 "entity_id".to_string(),
142 serde_json::json!(config.entity_id),
143 );
144 client.set_protocol_field("acs_url".to_string(), serde_json::json!(config.acs_url));
145 client.set_protocol_field("binding".to_string(), serde_json::json!(config.binding));
146 })
147 .await;
148
149 app_state
151 .update_client_status(client_id, ClientStatus::Connected)
152 .await;
153 let _ = status_tx.send(format!(
154 "[CLIENT] SAML client {} ready for IdP: {}",
155 client_id, remote_addr
156 ));
157 let _ = status_tx.send("__UPDATE_UI__".to_string());
158
159 let command_rx =
164 crate::client::command_support::register_command_channel(&app_state, client_id).await;
165 let cmd_task = tokio::spawn(Self::command_loop(
166 command_rx,
167 client_id,
168 app_state.clone(),
169 llm_client.clone(),
170 status_tx.clone(),
171 ));
172 app_state.register_client_task(client_id, cmd_task).await;
173
174 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
176 let event = Event::new(
177 &SAML_CLIENT_CONNECTED_EVENT,
178 serde_json::json!({
179 "idp_url": remote_addr.clone(),
180 }),
181 );
182
183 match call_llm_for_client(
184 &llm_client,
185 &app_state,
186 client_id.to_string(),
187 &instruction,
188 &String::new(),
189 Some(&event),
190 &crate::client::saml::actions::SamlClientProtocol,
191 &status_tx,
192 )
193 .await
194 {
195 Ok(ClientLlmResult {
196 actions,
197 memory_updates,
198 }) => {
199 if let Some(mem) = memory_updates {
200 app_state.set_memory_for_client(client_id, mem).await;
201 }
202
203 let protocol = crate::client::saml::actions::SamlClientProtocol;
206 for action in actions {
207 let result = match protocol.execute_action(action) {
208 Ok(result) => result,
209 Err(e) => {
210 error!("SAML client {} rejected action: {}", client_id, e);
211 continue;
212 }
213 };
214 match Self::apply_action(
215 result,
216 Dispatch::Inline,
217 client_id,
218 &app_state,
219 &llm_client,
220 &status_tx,
221 )
222 .await
223 {
224 Ok(Applied::Ran(detail)) => {
225 info!("SAML client {}: {}", client_id, detail)
226 }
227 Ok(Applied::Disconnect) => {
228 app_state.remove_client_handle(client_id).await;
229 let _ = status_tx.send("__UPDATE_UI__".to_string());
230 return Ok("0.0.0.0:0".parse().unwrap());
231 }
232 Err(e) => {
233 error!("SAML client {} action failed: {}", client_id, e);
234 }
235 }
236 }
237 }
238 Err(e) => {
239 error!("LLM error on saml_connected event: {}", e);
240 }
241 }
242 }
243
244 Ok("0.0.0.0:0".parse().unwrap())
249 }
250
251 async fn apply_action(
254 result: ClientActionResult,
255 dispatch: Dispatch,
256 client_id: ClientId,
257 app_state: &Arc<AppState>,
258 llm_client: &OllamaClient,
259 status_tx: &mpsc::UnboundedSender<String>,
260 ) -> Result<Applied> {
261 match result {
262 ClientActionResult::Custom { name, data } if name == "saml_initiate_sso" => {
263 let relay_state = data
264 .get("relay_state")
265 .and_then(|v| v.as_str())
266 .map(|s| s.to_string());
267 let force_authn = data
268 .get("force_authn")
269 .and_then(|v| v.as_bool())
270 .unwrap_or(false);
271
272 Self::initiate_sso(
273 client_id,
274 relay_state,
275 force_authn,
276 app_state.clone(),
277 llm_client.clone(),
278 status_tx.clone(),
279 dispatch,
280 )
281 .await?;
282
283 let sso_url = app_state
286 .with_client_mut(client_id, |client| {
287 client
288 .get_protocol_field("sso_url")
289 .and_then(|v| v.as_str())
290 .map(|s| s.to_string())
291 })
292 .await
293 .flatten();
294 Ok(Applied::Ran(match sso_url {
295 Some(url) => format!("saml_initiate_sso: AuthnRequest built, SSO URL {}", url),
296 None => "saml_initiate_sso: AuthnRequest built".to_string(),
297 }))
298 }
299 ClientActionResult::Custom { name, data } if name == "saml_validate_assertion" => {
300 let saml_response = data
301 .get("saml_response")
302 .and_then(|v| v.as_str())
303 .context("Missing 'saml_response' in saml_validate_assertion")?
304 .to_string();
305
306 Self::validate_assertion(
307 client_id,
308 saml_response,
309 app_state.clone(),
310 llm_client.clone(),
311 status_tx.clone(),
312 dispatch,
313 )
314 .await?;
315 Ok(Applied::Ran(
316 "saml_validate_assertion: response parsed and reported".to_string(),
317 ))
318 }
319 ClientActionResult::Custom { name, data } if name == "saml_parse_assertion" => {
320 let response_xml = data
321 .get("response_xml")
322 .and_then(|v| v.as_str())
323 .context("Missing 'response_xml' in saml_parse_assertion")?
324 .to_string();
325
326 let (success, status_code, assertion_data, attributes) =
327 Self::parse_saml_response(&response_xml)?;
328 Self::notify_parsed_response(
329 client_id,
330 success,
331 &status_code,
332 assertion_data,
333 attributes,
334 app_state,
335 llm_client,
336 status_tx,
337 dispatch,
338 )
339 .await;
340 Ok(Applied::Ran(format!(
341 "saml_parse_assertion: status {} (success={})",
342 status_code, success
343 )))
344 }
345 ClientActionResult::Disconnect => {
346 info!("SAML client {} disconnecting", client_id);
347 app_state
348 .update_client_status(client_id, ClientStatus::Disconnected)
349 .await;
350 let _ = status_tx.send("__UPDATE_UI__".to_string());
351 Ok(Applied::Disconnect)
352 }
353 ClientActionResult::Custom { name, .. } => Err(anyhow::anyhow!(
354 "SAML client cannot execute custom result '{}'",
355 name
356 )),
357 _ => Ok(Applied::Ran(
359 "no SAML operation performed (action produced no request)".to_string(),
360 )),
361 }
362 }
363
364 async fn command_loop(
369 mut command_rx: mpsc::Receiver<ClientCommand>,
370 client_id: ClientId,
371 app_state: Arc<AppState>,
372 llm_client: OllamaClient,
373 status_tx: mpsc::UnboundedSender<String>,
374 ) {
375 use crate::llm::actions::protocol_trait::Protocol;
376
377 let protocol = crate::client::saml::actions::SamlClientProtocol;
378
379 while let Some(command) = command_rx.recv().await {
380 let action = command.action.clone();
381 let outcome = match protocol.execute_action(action.clone()) {
382 Err(e) => Ok(ClientSendOutcome::Rejected {
383 error: e.to_string(),
384 }),
385 Ok(result) => Self::apply_action(
386 result,
387 Dispatch::Deferred,
388 client_id,
389 &app_state,
390 &llm_client,
391 &status_tx,
392 )
393 .await
394 .map(|applied| match applied {
395 Applied::Disconnect => ClientSendOutcome::Disconnected,
396 Applied::Ran(detail) => ClientSendOutcome::Executed { detail },
397 }),
398 };
399
400 let outcome_json = match &outcome {
401 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
402 Err(e) => serde_json::json!({"error": e.to_string()}),
403 };
404 app_state
405 .record_access_log(
406 AccessLogOwner::Client(client_id.as_u32()),
407 protocol.protocol_name(),
408 None,
409 "injected_action",
410 action,
411 vec![outcome_json],
412 )
413 .await;
414
415 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
416 if let Err(e) = &outcome {
417 error!("SAML client {} injected action failed: {}", client_id, e);
418 let _ = status_tx.send(format!(
419 "[WARN] Client {} injected action failed: {}",
420 client_id, e
421 ));
422 }
423 let _ = status_tx.send("__UPDATE_UI__".to_string());
424 crate::client::command_support::reply(command, outcome);
425
426 if disconnect {
427 break;
428 }
429 }
430
431 app_state.remove_client_handle(client_id).await;
432 let _ = status_tx.send("__UPDATE_UI__".to_string());
433 info!("SAML client {} command loop ended", client_id);
434 }
435
436 #[allow(clippy::too_many_arguments)]
438 pub async fn build_sso_request(
446 client_id: ClientId,
447 relay_state: Option<String>,
448 force_authn: bool,
449 app_state: &Arc<AppState>,
450 ) -> Result<(String, String, String)> {
451 info!("SAML client {} initiating SSO", client_id);
452
453 let config_opt = app_state
455 .with_client_mut(client_id, |client| {
456 let idp = client
457 .get_protocol_field("idp_url")
458 .and_then(|v| v.as_str())
459 .map(|s| s.to_string());
460 let entity = client
461 .get_protocol_field("entity_id")
462 .and_then(|v| v.as_str())
463 .map(|s| s.to_string());
464 let acs = client
465 .get_protocol_field("acs_url")
466 .and_then(|v| v.as_str())
467 .map(|s| s.to_string());
468 let bind = client
469 .get_protocol_field("binding")
470 .and_then(|v| v.as_str())
471 .map(|s| s.to_string());
472 (idp, entity, acs, bind)
473 })
474 .await;
475
476 let (idp_url, entity_id, acs_url, binding) = config_opt.context("Client not found")?;
477
478 let idp_url = idp_url.context("No IdP URL found")?;
479 let entity_id = entity_id.unwrap_or_else(|| SamlConfig::DEFAULT_ENTITY_ID.to_string());
480 let acs_url = acs_url.unwrap_or_else(|| SamlConfig::DEFAULT_ACS_URL.to_string());
481 let binding = binding.unwrap_or_else(|| "redirect".to_string());
482
483 let request_id = format!("_{}", uuid::Uuid::new_v4());
485 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
486
487 let authn_request = Self::generate_authn_request(
488 &request_id,
489 ×tamp,
490 &entity_id,
491 &acs_url,
492 force_authn,
493 );
494
495 info!("Generated SAML AuthnRequest with ID: {}", request_id);
496
497 let encoded_request = if binding == "redirect" {
499 Self::encode_request_redirect(&authn_request)?
500 } else {
501 base64::engine::Engine::encode(
503 &base64::engine::general_purpose::STANDARD,
504 authn_request.as_bytes(),
505 )
506 };
507
508 let mut sso_url = format!("{}?SAMLRequest={}", idp_url, encoded_request);
510 if let Some(state) = &relay_state {
511 sso_url.push_str(&format!("&RelayState={}", urlencoding::encode(state)));
512 }
513
514 info!("SAML SSO URL generated: {}", sso_url);
515
516 app_state
518 .with_client_mut(client_id, |client| {
519 client.set_protocol_field("request_id".to_string(), serde_json::json!(request_id));
520 client
521 .set_protocol_field("sso_url".to_string(), serde_json::json!(sso_url.clone()));
522 })
523 .await;
524
525 Ok((idp_url, sso_url, request_id))
526 }
527
528 pub async fn initiate_sso(
529 client_id: ClientId,
530 relay_state: Option<String>,
531 force_authn: bool,
532 app_state: Arc<AppState>,
533 llm_client: OllamaClient,
534 status_tx: mpsc::UnboundedSender<String>,
535 dispatch: Dispatch,
536 ) -> Result<()> {
537 let (idp_url, sso_url, request_id) =
538 Self::build_sso_request(client_id, relay_state, force_authn, &app_state).await?;
539
540 let event = Event::new(
542 &SAML_CLIENT_CONNECTED_EVENT,
543 serde_json::json!({
544 "idp_url": idp_url,
545 "sso_url": sso_url,
546 "request_id": request_id,
547 }),
548 );
549 Self::notify_event(
550 client_id,
551 event,
552 dispatch,
553 &app_state,
554 &llm_client,
555 &status_tx,
556 )
557 .await;
558
559 Ok(())
560 }
561
562 pub async fn validate_assertion(
564 client_id: ClientId,
565 saml_response: String,
566 app_state: Arc<AppState>,
567 llm_client: OllamaClient,
568 status_tx: mpsc::UnboundedSender<String>,
569 dispatch: Dispatch,
570 ) -> Result<()> {
571 info!("SAML client {} validating assertion", client_id);
572
573 let decoded = base64::engine::Engine::decode(
575 &base64::engine::general_purpose::STANDARD,
576 saml_response.as_bytes(),
577 )
578 .context("Failed to decode SAML response")?;
579
580 let response_xml =
581 String::from_utf8(decoded).context("Failed to parse SAML response as UTF-8")?;
582
583 let (success, status_code, assertion_data, attributes) =
585 Self::parse_saml_response(&response_xml)?;
586
587 info!(
588 "SAML response parsed - Success: {}, Status: {}",
589 success, status_code
590 );
591
592 Self::notify_parsed_response(
593 client_id,
594 success,
595 &status_code,
596 assertion_data,
597 attributes,
598 &app_state,
599 &llm_client,
600 &status_tx,
601 dispatch,
602 )
603 .await;
604
605 Ok(())
606 }
607
608 #[allow(clippy::too_many_arguments)]
612 async fn notify_parsed_response(
613 client_id: ClientId,
614 success: bool,
615 status_code: &str,
616 assertion_data: Option<serde_json::Value>,
617 attributes: Option<serde_json::Value>,
618 app_state: &Arc<AppState>,
619 llm_client: &OllamaClient,
620 status_tx: &mpsc::UnboundedSender<String>,
621 dispatch: Dispatch,
622 ) {
623 let event = Event::new(
624 &SAML_CLIENT_RESPONSE_RECEIVED_EVENT,
625 serde_json::json!({
626 "success": success,
627 "status_code": status_code,
628 "assertion": assertion_data,
629 "attributes": attributes,
630 }),
631 );
632 Self::notify_event(client_id, event, dispatch, app_state, llm_client, status_tx).await;
633 }
634
635 async fn notify_event(
637 client_id: ClientId,
638 event: Event,
639 dispatch: Dispatch,
640 app_state: &Arc<AppState>,
641 llm_client: &OllamaClient,
642 status_tx: &mpsc::UnboundedSender<String>,
643 ) {
644 match dispatch {
645 Dispatch::Inline => {
646 Self::raise_event(
647 client_id,
648 event,
649 app_state.clone(),
650 llm_client.clone(),
651 status_tx.clone(),
652 )
653 .await
654 }
655 Dispatch::Deferred => {
656 let handle = tokio::spawn(Self::raise_event(
657 client_id,
658 event,
659 app_state.clone(),
660 llm_client.clone(),
661 status_tx.clone(),
662 ));
663 app_state.register_client_task(client_id, handle).await;
665 }
666 }
667 }
668
669 async fn raise_event(
671 client_id: ClientId,
672 event: Event,
673 app_state: Arc<AppState>,
674 llm_client: OllamaClient,
675 status_tx: mpsc::UnboundedSender<String>,
676 ) {
677 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
678 return;
679 };
680
681 let protocol = Arc::new(crate::client::saml::actions::SamlClientProtocol::new());
682 let memory = app_state
683 .get_memory_for_client(client_id)
684 .await
685 .unwrap_or_default();
686
687 match call_llm_for_client(
688 &llm_client,
689 &app_state,
690 client_id.to_string(),
691 &instruction,
692 &memory,
693 Some(&event),
694 protocol.as_ref(),
695 &status_tx,
696 )
697 .await
698 {
699 Ok(ClientLlmResult {
700 actions,
701 memory_updates,
702 }) => {
703 if let Some(mem) = memory_updates {
704 app_state.set_memory_for_client(client_id, mem).await;
705 }
706
707 use crate::llm::actions::client_trait::{Client, ClientActionResult};
718 for action in actions {
719 let Ok(ClientActionResult::Custom { name, data }) =
720 protocol.execute_action(action.clone())
721 else {
722 continue;
723 };
724 if name != "saml_initiate_sso" {
725 info!(
726 "SAML client {} follow-up '{}' has no non-notifying path; skipped",
727 client_id, name
728 );
729 continue;
730 }
731 let relay_state = data
732 .get("relay_state")
733 .and_then(|v| v.as_str())
734 .map(|s| s.to_string());
735 let force_authn = data
736 .get("force_authn")
737 .and_then(|v| v.as_bool())
738 .unwrap_or(false);
739 match Self::build_sso_request(client_id, relay_state, force_authn, &app_state)
740 .await
741 {
742 Ok((_, sso_url, _)) => info!(
743 "SAML client {} follow-up built SSO URL {}",
744 client_id, sso_url
745 ),
746 Err(e) => error!(
747 "SAML client {} follow-up saml_initiate_sso failed: {}",
748 client_id, e
749 ),
750 }
751 }
752 }
753 Err(e) => {
754 error!("LLM error for SAML client {}: {}", client_id, e);
755 }
756 }
757 }
758
759 fn generate_authn_request(
761 request_id: &str,
762 timestamp: &str,
763 issuer: &str,
764 acs_url: &str,
765 force_authn: bool,
766 ) -> String {
767 format!(
768 r#"<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{}" Version="2.0" IssueInstant="{}" ForceAuthn="{}" IsPassive="false" AssertionConsumerServiceURL="{}">
769 <saml:Issuer>{}</saml:Issuer>
770 <samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" AllowCreate="true"/>
771</samlp:AuthnRequest>"#,
772 request_id, timestamp, force_authn, acs_url, issuer
773 )
774 }
775
776 fn encode_request_redirect(request: &str) -> Result<String> {
778 use flate2::write::DeflateEncoder;
779 use flate2::Compression;
780 use std::io::Write;
781
782 let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
783 encoder
784 .write_all(request.as_bytes())
785 .context("Failed to deflate SAML request")?;
786 let deflated = encoder.finish().context("Failed to finish deflation")?;
787
788 let encoded =
789 base64::engine::Engine::encode(&base64::engine::general_purpose::STANDARD, deflated);
790
791 Ok(urlencoding::encode(&encoded).to_string())
792 }
793
794 fn parse_saml_response(
796 response_xml: &str,
797 ) -> Result<(
798 bool,
799 String,
800 Option<serde_json::Value>,
801 Option<serde_json::Value>,
802 )> {
803 use quick_xml::events::Event;
804 use quick_xml::Reader;
805
806 let mut reader = Reader::from_str(response_xml);
807 reader.config_mut().trim_text(true);
808
809 let mut status_code = "urn:oasis:names:tc:SAML:2.0:status:Unknown".to_string();
810 const STATUS_SUCCESS: &str = "urn:oasis:names:tc:SAML:2.0:status:Success";
812 let mut subject = None;
813 let mut attributes = serde_json::Map::new();
814 let mut in_attribute = false;
815 let mut current_attr_name = None;
816
817 let mut buf = Vec::new();
818 loop {
819 match reader.read_event_into(&mut buf) {
820 Ok(Event::Start(ref e)) => {
821 match e.local_name().as_ref() {
826 b"Attribute" => {
827 in_attribute = true;
828 for attr in e.attributes() {
830 if let Ok(attr) = attr {
831 if attr.key.as_ref() == b"Name" {
832 if let Ok(value) =
833 attr.decode_and_unescape_value(reader.decoder())
834 {
835 current_attr_name = Some(value.to_string());
836 }
837 }
838 }
839 }
840 }
841 b"NameID" => {
842 if let Ok(Event::Text(e)) = reader.read_event_into(&mut buf) {
844 if let Ok(text) = e.unescape() {
845 subject = Some(text.to_string());
846 }
847 }
848 }
849 _ => {}
850 }
851 }
852 Ok(Event::Empty(ref e)) => {
853 if e.local_name().as_ref() == b"StatusCode" {
854 for attr in e.attributes() {
856 if let Ok(attr) = attr {
857 if attr.key.as_ref() == b"Value" {
858 if let Ok(value) =
859 attr.decode_and_unescape_value(reader.decoder())
860 {
861 status_code = value.to_string();
862 }
863 }
864 }
865 }
866 }
867 }
868 Ok(Event::End(ref e)) => match e.local_name().as_ref() {
869 b"Attribute" => {
870 in_attribute = false;
871 current_attr_name = None;
872 }
873 _ => {}
874 },
875 Ok(Event::Text(e)) => {
876 if in_attribute {
877 if let Some(name) = ¤t_attr_name {
878 if let Ok(text) = e.unescape() {
879 attributes
880 .insert(name.clone(), serde_json::json!(text.to_string()));
881 }
882 }
883 }
884 }
885 Ok(Event::Eof) => break,
886 Err(e) => return Err(anyhow::anyhow!("XML parse error: {}", e)),
887 _ => {}
888 }
889 buf.clear();
890 }
891
892 let success = status_code == STATUS_SUCCESS;
904
905 let assertion_data = if success {
906 Some(serde_json::json!({
907 "subject": subject,
908 "status_code": status_code.clone(),
909 }))
910 } else {
911 None
912 };
913
914 let attrs = if !attributes.is_empty() {
915 Some(serde_json::Value::Object(attributes))
916 } else {
917 None
918 };
919
920 Ok((success, status_code, assertion_data, attrs))
921 }
922}