Skip to main content

netget/client/saml/
mod.rs

1//! SAML client implementation
2pub 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
24/// What [`SamlClient::apply_action`] did with one executed action. SAML rides on HTTP
25/// bindings rather than a socket NetGet owns, so no byte count can honestly be reported.
26enum Applied {
27    /// The action ran; the string describes the effect.
28    Ran(String),
29    /// The action asked to end the session.
30    Disconnect,
31}
32
33/// Whether an event is reported to the LLM inline or from its own task.
34///
35/// Public because `initiate_sso` / `validate_assertion` are: both are entry points a caller
36/// outside this module can drive, and both have to say which way the resulting event goes.
37#[derive(Clone, Copy)]
38pub enum Dispatch {
39    /// Raise it here and now. Used by the connected-event LLM path.
40    Inline,
41    /// Hand it to a registered task. Used by the injected-command loop, so a manual
42    /// (human-answered) routing rule on `saml_response_received` cannot hold up the
43    /// command's outcome, or the next injected command.
44    Deferred,
45}
46
47/// The Service Provider side of the configuration, as the caller declared it.
48///
49/// `entity_id`, `acs_url` and `binding` are declared startup parameters and arrive on
50/// `ConnectContext::startup_params`. They are resolved once, at connect, and written into
51/// `protocol_data` -- which is where every later step reads them from, and which
52/// `cli/client_startup.rs` leaves `Value::Null` until someone writes it.
53struct SamlConfig {
54    entity_id: String,
55    acs_url: String,
56    binding: String,
57}
58
59impl SamlConfig {
60    /// NetGet's own placeholder SP identity, used when the caller names none.
61    const DEFAULT_ENTITY_ID: &'static str = "urn:netget:sp";
62    /// Placeholder ACS URL, likewise.
63    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        // Refused rather than silently reinterpreted. `build_sso_request` treats anything that
76        // is not exactly "redirect" as HTTP-POST, so "Redirect" or a typo would quietly change
77        // the binding -- an advertised knob doing something other than what it says is worse
78        // than one that does nothing.
79        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
101/// SAML client that authenticates with a SAML Identity Provider
102pub struct SamlClient;
103
104impl SamlClient {
105    /// Connect to a SAML IdP with integrated LLM actions
106    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        // For SAML, "connection" is logical - we're preparing to authenticate
115        // The actual communication happens via HTTP requests to the IdP
116
117        info!(
118            "SAML client {} initialized for IdP: {}",
119            client_id, remote_addr
120        );
121
122        // The three declared parameters. They used to be written here as *hardcoded*
123        // placeholders, under a comment saying they could be "overridden by startup params" --
124        // and `ctx.startup_params` was dropped, so nothing could override them. What the
125        // caller passed was silently replaced by `urn:netget:sp` and
126        // `http://localhost:8080/saml/acs`, which are the SP identity the AuthnRequest claims
127        // and the address it asks the IdP to post the assertion to. Errors propagate: a
128        // wrong-typed value must name itself, not panic the task that is starting the client.
129        let config = SamlConfig::resolve(startup_params.as_ref())?;
130
131        // Store the IdP URL and the resolved SP configuration in protocol_data. Everything
132        // downstream (`build_sso_request`) reads it back from there.
133        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        // Update status
150        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        // Command channel for injected actions (the dashboard's [ initiate_sso ] /
160        // [ validate_assertion ] / [ disconnect ]). Registered BEFORE the connected-event
161        // LLM call below, which is awaited inline and which a manual `*` routing rule can
162        // park for minutes - the operator must be able to act while it waits.
163        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        // Call LLM with saml_connected event
175        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                    // Execute the model's actions through the same path injected commands
204                    // use. They used to be dropped on the floor here.
205                    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        // No idle-poll task: the command loop is this client's long-lived task and it ends
245        // when the client is removed (`remove_client` drops the command sender).
246
247        // Return a dummy local address (SAML is HTTP-based)
248        Ok("0.0.0.0:0".parse().unwrap())
249    }
250
251    /// Run one executed action. Shared by the connected-event LLM path and injected
252    /// commands so each verb's implementation exists exactly once.
253    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                // The AuthnRequest is carried by the user's browser, not by NetGet: report
284                // the URL that was built rather than any byte count.
285                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            // WaitForMore / NoAction / SendData / nested Multiple: nothing to do.
358            _ => Ok(Applied::Ran(
359                "no SAML operation performed (action produced no request)".to_string(),
360            )),
361        }
362    }
363
364    /// Drain injected commands until the channel closes (the client was removed) or an
365    /// injected `disconnect` ends the session. Each operation is awaited, so the reported
366    /// [`ClientSendOutcome`] describes work that really completed; a failure is reported as
367    /// an error, never as a success, and nothing here can invent an assertion or a session.
368    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    /// Initiate SAML SSO authentication
437    #[allow(clippy::too_many_arguments)]
438    /// Build the AuthnRequest and SSO URL and store them. Raises no event, calls no LLM.
439    ///
440    /// Split out so a follow-up action the model returns for a SAML event can run without
441    /// re-entering `notify_event`. That path would otherwise be mutually recursive --
442    /// notify_event -> apply_action -> initiate_sso -> notify_event -- which rustc rejects
443    /// while inferring the async types, quite apart from letting one SSO drive the model
444    /// round in circles.
445    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        // Get IdP URL and SP configuration from client
454        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        // Generate SAML AuthnRequest
484        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            &timestamp,
490            &entity_id,
491            &acs_url,
492            force_authn,
493        );
494
495        info!("Generated SAML AuthnRequest with ID: {}", request_id);
496
497        // For HTTP-Redirect binding, we need to deflate and base64 encode
498        let encoded_request = if binding == "redirect" {
499            Self::encode_request_redirect(&authn_request)?
500        } else {
501            // HTTP-POST binding uses base64 only
502            base64::engine::Engine::encode(
503                &base64::engine::general_purpose::STANDARD,
504                authn_request.as_bytes(),
505            )
506        };
507
508        // Build SSO URL
509        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        // Store request ID for validation
517        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        // Notify LLM about SSO URL
541        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    /// Validate SAML assertion from IdP response
563    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        // Decode base64 SAML response
574        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        // Parse SAML response
584        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    /// Raise `saml_response_received` for an already-parsed IdP response. Shared by
609    /// `validate_assertion` (base64 wrapper) and the `parse_assertion` action (raw XML), so
610    /// both report the same event shape.
611    #[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    /// Report one event to the LLM, inline or from a registered task.
636    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                // Registered so stop_client aborts an in-flight LLM call for this event.
664                app_state.register_client_task(client_id, handle).await;
665            }
666        }
667    }
668
669    /// The event -> LLM round-trip itself.
670    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                // Execute what the model asked for. These were discarded, so answering a
708                // SAML event did nothing -- a model that read an IdP response and wanted
709                // to start a fresh AuthnRequest (a re-authentication, which is exactly
710                // what force_authn exists for) was silently ignored.
711                //
712                // Dispatch goes through `build_sso_request`, which does the work and
713                // raises no event. Calling `initiate_sso` here instead would be mutually
714                // recursive -- notify_event -> apply_action -> initiate_sso ->
715                // notify_event -- which rustc rejects while inferring the async types,
716                // quite apart from letting one SSO drive the model round in circles.
717                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    /// Generate SAML AuthnRequest XML
760    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    /// Encode SAML request for HTTP-Redirect binding
777    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    /// Parse SAML response XML
795    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        // The only value that means the IdP authenticated the subject (SAML 2.0 Core 3.2.2.2).
811        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                    // `local_name()` strips the namespace prefix. Matching the raw qualified
822                    // name only recognised the `saml:` prefix, so a document using `saml2:`
823                    // or `sa:` - both perfectly conforming, and `saml2:` is what ADFS and
824                    // Shibboleth emit - parsed as if it had no subject and no attributes.
825                    match e.local_name().as_ref() {
826                        b"Attribute" => {
827                            in_attribute = true;
828                            // Extract attribute name
829                            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                            // Read subject
843                            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                        // Extract status code
855                        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) = &current_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        // Exact match, not `status_code.contains("Success")`.
893        //
894        // The StatusCode Value is chosen by whoever sent the response - which on this path is
895        // an unauthenticated peer, since nothing here verifies a signature. `contains` is
896        // satisfied by `...:status:NotSuccess`, by `Success-denied`, by any string with those
897        // seven characters anywhere in it. The model reads `success` to decide whether to
898        // treat the subject as signed in, so a substring test on an attacker-controlled field
899        // was an affirmative default on the one field that matters.
900        //
901        // SAML 2.0 Core 3.2.2.2 defines exactly one success value; a nested second-level
902        // StatusCode never carries it.
903        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}