Skip to main content

netget/server/saml_sp/
actions.rs

1//! SAML Service Provider (SP) protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// SAML SP protocol action handler
15pub struct SamlSpProtocol;
16
17impl SamlSpProtocol {
18    pub fn new() -> Self {
19        Self
20    }
21}
22
23// Implement Protocol trait (common functionality)
24impl Protocol for SamlSpProtocol {
25    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
26        Vec::new()
27    }
28    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
29        vec![
30            send_authn_request_action(),
31            process_assertion_action(),
32            send_metadata_action(),
33            send_error_response_action(),
34        ]
35    }
36    fn protocol_name(&self) -> &'static str {
37        "SamlSp"
38    }
39    fn get_event_types(&self) -> Vec<EventType> {
40        get_saml_sp_event_types()
41    }
42    fn stack_name(&self) -> &'static str {
43        "ETH>IP>TCP>HTTP>SAML-SP"
44    }
45    fn keywords(&self) -> Vec<&'static str> {
46        vec![
47            "saml sp",
48            "saml service provider",
49            "service provider",
50            "sp",
51            "saml-sp",
52        ]
53    }
54    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
55        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
56
57        ProtocolMetadataV2::builder()
58            .state(DevelopmentState::Experimental)
59            .implementation(
60                "SAML 2.0 SP endpoints (/login, /acs, /metadata) over hyper HTTP/1.1. Simulator: \
61                 the raw SAMLResponse body is handed to the model as text and the model decides \
62                 who the user is. NetGet performs no XML signature verification.",
63            )
64            .llm_control(
65                "Whether to accept an assertion at all, which user_id and attributes to read out \
66                 of it, the AuthnRequest XML sent to the IDP, and error pages.",
67            )
68            .e2e_testing("SAML IDP test server")
69            .notes(
70                "Accepts unsigned and forged assertions: nothing checks <ds:Signature>, issuer, \
71                 audience, NotOnOrAfter or assertion-ID replay unless the model's instruction \
72                 tells it to look, and it has no key to check a signature against. The session \
73                 cookie is the bare user id with no server-side session store.",
74            )
75            .max_inbound_bytes(crate::server::saml_sp::MAX_REQUEST_BYTES)
76            .request_only(
77                "SAML is HTTP request/response; a server cannot send a peer anything unprompted",
78            )
79            .build()
80    }
81    fn description(&self) -> &'static str {
82        "SAML 2.0 Service Provider simulator (LLM reads the assertion; no signature is verified)"
83    }
84    fn example_prompt(&self) -> &'static str {
85        "Start a SAML Service Provider on port 8081. Accept assertions from IDP and grant access to authenticated users"
86    }
87    fn group_name(&self) -> &'static str {
88        "Authentication"
89    }
90    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
91        use crate::llm::actions::StartupExamples;
92        use serde_json::json;
93
94        // Deterministic: serve SP metadata for every request, no LLM call.
95        let script = r#"import json, sys
96data = json.load(sys.stdin)
97event = data["event"]
98if data["event_type_id"] == "saml_sp_request":
99    actions = [{"type": "send_metadata",
100                "metadata_xml": '<EntityDescriptor entityID="netget-sp"/>'}]
101else:
102    actions = []
103print(json.dumps({"actions": actions}))"#;
104
105        StartupExamples::new(
106            // LLM mode: LLM handles SAML SP authorization
107            json!({
108                "type": "open_server",
109                "port": 8081,
110                "base_stack": "saml-sp",
111                "instruction": "Accept SAML assertions from IDP and grant access to authenticated users"
112            }),
113            // Script mode: Code-based SAML SP handling
114            json!({
115                "type": "open_server",
116                "port": 8081,
117                "base_stack": "saml-sp",
118                "event_handlers": [{
119                    "event_pattern": "saml_sp_request",
120                    "handler": {
121                        "type": "script",
122                        "language": "python",
123                        "code": script
124                    }
125                }]
126            }),
127            // Static mode: Fixed SAML SP action
128            json!({
129                "type": "open_server",
130                "port": 8081,
131                "base_stack": "saml-sp",
132                "event_handlers": [{
133                    "event_pattern": "saml_sp_request",
134                    "handler": {
135                        "type": "static",
136                        "actions": [{
137                            "type": "process_assertion",
138                            "user_id": "testuser",
139                            "attributes": {
140                                "email": "test@example.com",
141                                "role": "user"
142                            }
143                        }]
144                    }
145                }]
146            }),
147        )
148    }
149}
150
151// Implement Server trait (server-specific functionality)
152impl Server for SamlSpProtocol {
153    fn spawn(
154        &self,
155        ctx: crate::protocol::SpawnContext,
156    ) -> std::pin::Pin<
157        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
158    > {
159        Box::pin(async move {
160            use crate::server::saml_sp::SamlSpServer;
161            SamlSpServer::spawn_with_llm_actions(
162                ctx.legacy_listen_addr(),
163                ctx.llm_client,
164                ctx.state,
165                ctx.status_tx,
166                ctx.server_id,
167            )
168            .await
169        })
170    }
171    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
172        let action_type = action
173            .get("type")
174            .and_then(|v| v.as_str())
175            .context("Missing 'type' field in action")?;
176
177        match action_type {
178            "send_authn_request" => self.execute_send_authn_request(action),
179            "process_assertion" => self.execute_process_assertion(action),
180            "send_metadata" => self.execute_send_metadata(action),
181            "send_error_response" => self.execute_send_error_response(action),
182            _ => Err(anyhow::anyhow!("Unknown SAML SP action: {action_type}")),
183        }
184    }
185}
186
187impl SamlSpProtocol {
188    /// Execute send_authn_request sync action
189    fn execute_send_authn_request(&self, action: serde_json::Value) -> Result<ActionResult> {
190        let request_xml = action
191            .get("request_xml")
192            .and_then(|v| v.as_str())
193            .context("Missing 'request_xml' field")?;
194
195        let idp_sso_url = action
196            .get("idp_sso_url")
197            .and_then(|v| v.as_str())
198            .context("Missing 'idp_sso_url' field")?;
199
200        let relay_state = action.get("relay_state").and_then(|v| v.as_str());
201
202        let binding = action
203            .get("binding")
204            .and_then(|v| v.as_str())
205            .unwrap_or("HTTP-Redirect");
206
207        let response_html = match binding {
208            "HTTP-POST" => build_authn_post_form(request_xml, idp_sso_url, relay_state),
209            _ => build_authn_redirect(request_xml, idp_sso_url, relay_state),
210        };
211
212        let response_data = json!({
213            "status": 200,
214            "headers": {
215                "Content-Type": "text/html; charset=utf-8"
216            },
217            "body": response_html
218        });
219
220        Ok(ActionResult::Output(
221            serde_json::to_vec(&response_data).context("Failed to serialize AuthnRequest")?,
222        ))
223    }
224
225    /// Execute process_assertion sync action
226    fn execute_process_assertion(&self, action: serde_json::Value) -> Result<ActionResult> {
227        let user_id = action
228            .get("user_id")
229            .and_then(|v| v.as_str())
230            .context("Missing 'user_id' field")?;
231
232        let attributes = action
233            .get("attributes")
234            .and_then(|v| v.as_object())
235            .map(|obj| {
236                obj.iter()
237                    .map(|(k, v)| format!("{}: {}", k, v))
238                    .collect::<Vec<_>>()
239                    .join(", ")
240            })
241            .unwrap_or_default();
242
243        let success_html = format!(
244            r#"<!DOCTYPE html>
245<html>
246<head>
247    <title>Login Successful</title>
248</head>
249<body>
250    <h1>Authentication Successful</h1>
251    <p>Welcome, {}!</p>
252    <p>Attributes: {}</p>
253</body>
254</html>"#,
255            escape_html(user_id),
256            escape_html(&attributes)
257        );
258
259        // `user_id` is whatever the model read out of an attacker-supplied assertion, so it
260        // is neither HTML- nor cookie-safe as it stands. Unescaped it was reflected XSS in
261        // the page, and raw in `Set-Cookie` it could inject cookie attributes or CR/LF.
262        let response_data = json!({
263            "status": 200,
264            "headers": {
265                "Content-Type": "text/html; charset=utf-8",
266                "Set-Cookie": format!("session_id={}; HttpOnly; SameSite=Lax", cookie_value(user_id))
267            },
268            "body": success_html
269        });
270
271        Ok(ActionResult::Output(
272            serde_json::to_vec(&response_data).context("Failed to serialize assertion response")?,
273        ))
274    }
275
276    /// Execute send_metadata sync action
277    fn execute_send_metadata(&self, action: serde_json::Value) -> Result<ActionResult> {
278        let metadata_xml = action
279            .get("metadata_xml")
280            .and_then(|v| v.as_str())
281            .context("Missing 'metadata_xml' field")?;
282
283        let response_data = json!({
284            "status": 200,
285            "headers": {
286                "Content-Type": "application/samlmetadata+xml"
287            },
288            "body": metadata_xml
289        });
290
291        Ok(ActionResult::Output(
292            serde_json::to_vec(&response_data).context("Failed to serialize metadata response")?,
293        ))
294    }
295
296    /// Execute send_error_response sync action
297    fn execute_send_error_response(&self, action: serde_json::Value) -> Result<ActionResult> {
298        let error_message = action
299            .get("error_message")
300            .and_then(|v| v.as_str())
301            .unwrap_or("Authorization failed");
302
303        // `as u16` truncates: 65736 becomes 200, and a 2xx is the only thing that reads as a
304        // completed sign-in. Refuse the wrap and keep the refusal a refusal.
305        let status_code = action
306            .get("status_code")
307            .and_then(|v| v.as_u64())
308            .and_then(|raw| u16::try_from(raw).ok())
309            .filter(|s| (400..=599).contains(s))
310            .unwrap_or(403);
311
312        let error_html = format!(
313            "<html><body><h1>Authorization Error</h1><p>{}</p></body></html>",
314            escape_html(error_message)
315        );
316
317        let response_data = json!({
318            "status": status_code,
319            "headers": {
320                "Content-Type": "text/html; charset=utf-8"
321            },
322            "body": error_html
323        });
324
325        Ok(ActionResult::Output(
326            serde_json::to_vec(&response_data).context("Failed to serialize error response")?,
327        ))
328    }
329}
330
331/// Escape text for interpolation into an HTML attribute or text node.
332///
333/// Every value placed into these pages is model output derived from an untrusted request —
334/// the user id and attributes lifted out of a SAML assertion, a RelayState echoed back, an
335/// IDP URL. Unescaped, a `"` or `<` broke out of the surrounding attribute and injected
336/// markup into a page the browser renders (and, for the POST binding, auto-submits).
337fn escape_html(text: &str) -> String {
338    let mut out = String::with_capacity(text.len());
339    for c in text.chars() {
340        match c {
341            '&' => out.push_str("&amp;"),
342            '<' => out.push_str("&lt;"),
343            '>' => out.push_str("&gt;"),
344            '"' => out.push_str("&quot;"),
345            '\'' => out.push_str("&#39;"),
346            _ => out.push(c),
347        }
348    }
349    out
350}
351
352/// Percent-encode a value so it is safe as a cookie value.
353///
354/// Strips the `;`, `,`, whitespace and CR/LF that would otherwise let a crafted user id
355/// append cookie attributes or split the response header.
356fn cookie_value(value: &str) -> String {
357    urlencoding::encode(value).into_owned()
358}
359
360/// Build SAML HTTP-POST form for AuthnRequest
361fn build_authn_post_form(
362    request_xml: &str,
363    idp_sso_url: &str,
364    relay_state: Option<&str>,
365) -> String {
366    let encoded_request =
367        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, request_xml);
368    let relay_state_field = relay_state
369        .map(|rs| {
370            format!(
371                r#"<input type="hidden" name="RelayState" value="{}" />"#,
372                escape_html(rs)
373            )
374        })
375        .unwrap_or_default();
376
377    format!(
378        r#"<!DOCTYPE html>
379<html>
380<head>
381    <title>SAML POST Binding</title>
382</head>
383<body onload="document.forms[0].submit()">
384    <noscript>
385        <p><strong>Note:</strong> Your browser does not support JavaScript, please click Submit to continue.</p>
386    </noscript>
387    <form method="post" action="{}">
388        <input type="hidden" name="SAMLRequest" value="{}" />
389        {}
390        <noscript>
391            <input type="submit" value="Submit" />
392        </noscript>
393    </form>
394</body>
395</html>"#,
396        escape_html(idp_sso_url),
397        encoded_request,
398        relay_state_field
399    )
400}
401
402/// Build SAML HTTP-Redirect for AuthnRequest
403fn build_authn_redirect(request_xml: &str, idp_sso_url: &str, relay_state: Option<&str>) -> String {
404    let encoded_request =
405        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, request_xml);
406    let relay_param = relay_state
407        .map(|rs| format!("&RelayState={}", urlencoding::encode(rs)))
408        .unwrap_or_default();
409    let redirect_url = format!(
410        "{}?SAMLRequest={}{}",
411        idp_sso_url,
412        urlencoding::encode(&encoded_request),
413        relay_param
414    );
415
416    format!(
417        r#"<!DOCTYPE html>
418<html>
419<head>
420    <title>Redirecting to IDP</title>
421    <meta http-equiv="refresh" content="0; url={}" />
422</head>
423<body>
424    <p>Redirecting to Identity Provider...</p>
425    <p>If you are not redirected automatically, <a href="{}">click here</a>.</p>
426</body>
427</html>"#,
428        escape_html(&redirect_url),
429        escape_html(&redirect_url)
430    )
431}
432
433// Action definitions
434fn send_authn_request_action() -> ActionDefinition {
435    ActionDefinition {
436        name: "send_authn_request".to_string(),
437        description: "Initiate SAML authentication by sending AuthnRequest to IDP".to_string(),
438        parameters: vec![
439            Parameter {
440                name: "request_xml".to_string(),
441                type_hint: "string".to_string(),
442                description: "SAML AuthnRequest XML".to_string(),
443                required: true,
444            },
445            Parameter {
446                name: "idp_sso_url".to_string(),
447                type_hint: "string".to_string(),
448                description: "IDP Single Sign-On URL".to_string(),
449                required: true,
450            },
451            Parameter {
452                name: "relay_state".to_string(),
453                type_hint: "string".to_string(),
454                description: "Optional RelayState to maintain application state".to_string(),
455                required: false,
456            },
457            Parameter {
458                name: "binding".to_string(),
459                type_hint: "string".to_string(),
460                description: "Binding type: HTTP-Redirect or HTTP-POST (default: HTTP-Redirect)"
461                    .to_string(),
462                required: false,
463            },
464        ],
465        example: json!({
466            "type": "send_authn_request",
467            "request_xml": "<samlp:AuthnRequest>...</samlp:AuthnRequest>",
468            "idp_sso_url": "https://idp.example.com/sso",
469            "binding": "HTTP-Redirect"
470        }),
471        log_template: Some(
472            LogTemplate::new()
473                .with_info("-> SAML AuthnRequest via {binding}")
474                .with_debug("SAML AuthnRequest to {idp_sso_url}, binding={binding}"),
475        ),
476    }
477}
478
479fn process_assertion_action() -> ActionDefinition {
480    ActionDefinition {
481        name: "process_assertion".to_string(),
482        description: "Accept a SAML assertion and start a session for the named user. NetGet does \
483                      not verify the assertion's signature, issuer, audience or expiry — if any \
484                      of that matters, check it yourself in the assertion XML before calling this, \
485                      and use send_error_response to reject."
486            .to_string(),
487        parameters: vec![
488            Parameter {
489                name: "user_id".to_string(),
490                type_hint: "string".to_string(),
491                description: "User identifier from assertion (becomes the session cookie value)"
492                    .to_string(),
493                required: true,
494            },
495            Parameter {
496                name: "attributes".to_string(),
497                type_hint: "object".to_string(),
498                description: "User attributes from assertion (e.g., email, roles)".to_string(),
499                required: false,
500            },
501        ],
502        example: json!({
503            "type": "process_assertion",
504            "user_id": "john.doe",
505            "attributes": {
506                "email": "john.doe@example.com",
507                "role": "admin"
508            }
509        }),
510        log_template: Some(
511            LogTemplate::new()
512                .with_info("-> SAML session: {user_id}")
513                .with_debug("SAML assertion processed: user={user_id}"),
514        ),
515    }
516}
517
518fn send_metadata_action() -> ActionDefinition {
519    ActionDefinition {
520        name: "send_metadata".to_string(),
521        description: "Send SP metadata XML describing ACS endpoint and signing certificates"
522            .to_string(),
523        parameters: vec![Parameter {
524            name: "metadata_xml".to_string(),
525            type_hint: "string".to_string(),
526            description: "SAML SP metadata XML".to_string(),
527            required: true,
528        }],
529        example: json!({
530            "type": "send_metadata",
531            "metadata_xml": "<EntityDescriptor>...</EntityDescriptor>"
532        }),
533        log_template: Some(
534            LogTemplate::new()
535                .with_info("-> SAML SP metadata sent")
536                .with_debug("SAML SP metadata response sent"),
537        ),
538    }
539}
540
541fn send_error_response_action() -> ActionDefinition {
542    ActionDefinition {
543        name: "send_error_response".to_string(),
544        description: "Send an error response when assertion validation fails".to_string(),
545        parameters: vec![
546            Parameter {
547                name: "error_message".to_string(),
548                type_hint: "string".to_string(),
549                description: "Error message to display".to_string(),
550                required: true,
551            },
552            Parameter {
553                name: "status_code".to_string(),
554                type_hint: "number".to_string(),
555                description: "HTTP status code (default: 403)".to_string(),
556                required: false,
557            },
558        ],
559        example: json!({
560            "type": "send_error_response",
561            "error_message": "Invalid assertion signature",
562            "status_code": 403
563        }),
564        log_template: Some(
565            LogTemplate::new()
566                .with_info("-> SAML error {status_code}: {error_message}")
567                .with_debug("SAML SP error response: {status_code} - {error_message}"),
568        ),
569    }
570}
571
572// ============================================================================
573// SAML SP Event Type Constants
574// ============================================================================
575
576/// SAML SP request event - triggered when client sends request
577pub static SAML_SP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
578    EventType::new(
579        "saml_sp_request",
580        "Received SAML assertion response or metadata request",
581        json!({
582            "type": "process_assertion",
583            "user_id": "john.doe",
584            "attributes": {
585                "email": "john.doe@example.com",
586                "role": "user"
587            }
588        }),
589    )
590    .with_alternative_example(json!({
591        "type": "send_authn_request",
592        "request_xml": "<samlp:AuthnRequest>...</samlp:AuthnRequest>",
593        "idp_sso_url": "http://localhost:8080/sso",
594        "binding": "HTTP-Redirect"
595    }))
596    .with_alternative_example(json!({
597        "type": "send_error_response",
598        "error_message": "Assertion is from an untrusted issuer",
599        "status_code": 403
600    }))
601    .with_log_template(
602        LogTemplate::new()
603            .with_info("{client_ip} SAML SP {method} {path} ({duration_ms}ms)")
604            .with_debug("SAML SP request from {client_ip}: {method} {path}")
605            .with_trace("SAML SP request: {json_pretty(.)}"),
606    )
607    .with_parameters(vec![
608        Parameter {
609            name: "method".to_string(),
610            type_hint: "string".to_string(),
611            description: "HTTP method (GET or POST)".to_string(),
612            required: true,
613        },
614        Parameter {
615            name: "path".to_string(),
616            type_hint: "string".to_string(),
617            description: "Request path (e.g., /acs, /metadata, /login)".to_string(),
618            required: true,
619        },
620        Parameter {
621            name: "query".to_string(),
622            type_hint: "string".to_string(),
623            description: "Query parameters".to_string(),
624            required: false,
625        },
626        Parameter {
627            name: "headers".to_string(),
628            type_hint: "array".to_string(),
629            description: "HTTP headers".to_string(),
630            required: true,
631        },
632        Parameter {
633            name: "body".to_string(),
634            type_hint: "string".to_string(),
635            description: "Request body (may contain SAMLResponse)".to_string(),
636            required: false,
637        },
638        Parameter {
639            name: "client_ip".to_string(),
640            type_hint: "string".to_string(),
641            description: "IP address of the requesting client".to_string(),
642            required: true,
643        },
644    ])
645    .with_actions(vec![
646        send_authn_request_action(),
647        process_assertion_action(),
648        send_metadata_action(),
649        send_error_response_action(),
650    ])
651});
652
653fn get_saml_sp_event_types() -> Vec<EventType> {
654    vec![SAML_SP_REQUEST_EVENT.clone()]
655}