1use 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
14pub struct SamlSpProtocol;
16
17impl SamlSpProtocol {
18 pub fn new() -> Self {
19 Self
20 }
21}
22
23impl 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 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 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 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 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
151impl 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 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 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 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 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 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 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
331fn 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("&"),
342 '<' => out.push_str("<"),
343 '>' => out.push_str(">"),
344 '"' => out.push_str("""),
345 '\'' => out.push_str("'"),
346 _ => out.push(c),
347 }
348 }
349 out
350}
351
352fn cookie_value(value: &str) -> String {
357 urlencoding::encode(value).into_owned()
358}
359
360fn 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
402fn 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
433fn 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
572pub 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}