1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15pub static SAML_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17 EventType::new(
18 "saml_connected",
19 "SAML client initialized and ready to authenticate",
20 json!({
21 "type": "parse_assertion",
22 "response_xml": "<samlp:Response...>"
23 }),
24 )
25 .with_parameters(vec![Parameter {
26 name: "idp_url".to_string(),
27 type_hint: "string".to_string(),
28 description: "Identity Provider URL".to_string(),
29 required: true,
30 }])
31});
32
33pub static SAML_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
35 EventType::new(
36 "saml_response_received",
37 "SAML authentication response received from IdP",
38 json!({
39 "type": "parse_assertion",
40 "response_xml": "<samlp:Response...>"
41 }),
42 )
43 .with_parameters(vec![
44 Parameter {
45 name: "success".to_string(),
46 type_hint: "boolean".to_string(),
47 description: "Whether authentication was successful".to_string(),
48 required: true,
49 },
50 Parameter {
51 name: "status_code".to_string(),
52 type_hint: "string".to_string(),
53 description: "SAML status code".to_string(),
54 required: true,
55 },
56 Parameter {
57 name: "assertion".to_string(),
58 type_hint: "object".to_string(),
59 description: "SAML assertion data if successful".to_string(),
60 required: false,
61 },
62 Parameter {
63 name: "attributes".to_string(),
64 type_hint: "object".to_string(),
65 description: "User attributes from IdP".to_string(),
66 required: false,
67 },
68 ])
69});
70
71pub struct SamlClientProtocol;
73
74impl SamlClientProtocol {
75 pub fn new() -> Self {
76 Self
77 }
78}
79
80impl Protocol for SamlClientProtocol {
82 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
83 vec![
84 ParameterDefinition {
85 name: "entity_id".to_string(),
86 description: "Service Provider entity ID".to_string(),
87 type_hint: "string".to_string(),
88 required: false,
89 example: json!("https://example.com/saml/sp"),
90 default: None,
91 },
92 ParameterDefinition {
93 name: "acs_url".to_string(),
94 description: "Assertion Consumer Service URL".to_string(),
95 type_hint: "string".to_string(),
96 required: false,
97 example: json!("https://example.com/saml/acs"),
98 default: None,
99 },
100 ParameterDefinition {
101 name: "binding".to_string(),
102 description: "SAML binding type (redirect or post)".to_string(),
103 type_hint: "string".to_string(),
104 required: false,
105 example: json!("redirect"),
106 default: None,
107 },
108 ]
109 }
110 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
111 vec![
112 ActionDefinition {
113 name: "initiate_sso".to_string(),
114 description: "Initiate SAML Single Sign-On with IdP".to_string(),
115 parameters: vec![
116 Parameter {
117 name: "relay_state".to_string(),
118 type_hint: "string".to_string(),
119 description: "Optional relay state to preserve across authentication"
120 .to_string(),
121 required: false,
122 },
123 Parameter {
124 name: "force_authn".to_string(),
125 type_hint: "boolean".to_string(),
126 description: "Force re-authentication at IdP".to_string(),
127 required: false,
128 },
129 ],
130 example: json!({
131 "type": "initiate_sso",
132 "relay_state": "/protected/resource",
133 "force_authn": false
134 }),
135 log_template: None,
136 },
137 ActionDefinition {
138 name: "validate_assertion".to_string(),
139 description: "Validate a SAML assertion received from IdP".to_string(),
140 parameters: vec![Parameter {
141 name: "saml_response".to_string(),
142 type_hint: "string".to_string(),
143 description: "Base64-encoded SAML response".to_string(),
144 required: true,
145 }],
146 example: json!({
147 "type": "validate_assertion",
148 "saml_response": "PHNhbWxwOlJlc3BvbnNlLi4uPg=="
149 }),
150 log_template: None,
151 },
152 ActionDefinition {
153 name: "disconnect".to_string(),
154 description: "Disconnect from SAML IdP".to_string(),
155 parameters: vec![],
156 example: json!({
157 "type": "disconnect"
158 }),
159 log_template: None,
160 },
161 ]
162 }
163 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
164 vec![ActionDefinition {
165 name: "parse_assertion".to_string(),
166 description: "Parse SAML assertion from response".to_string(),
167 parameters: vec![Parameter {
168 name: "response_xml".to_string(),
169 type_hint: "string".to_string(),
170 description: "SAML response XML".to_string(),
171 required: true,
172 }],
173 example: json!({
174 "type": "parse_assertion",
175 "response_xml": "<samlp:Response...>"
176 }),
177 log_template: Some(
178 LogTemplate::new()
179 .with_info("-> SAML parse assertion")
180 .with_debug("SAML parse_assertion"),
181 ),
182 }]
183 }
184 fn protocol_name(&self) -> &'static str {
185 "SAML"
186 }
187 fn get_event_types(&self) -> Vec<EventType> {
188 vec![
189 EventType::new(
190 "saml_connected",
191 "Triggered when SAML client is initialized",
192 json!({"type": "placeholder", "event_id": "saml_connected"}),
193 ),
194 EventType::new(
195 "saml_response_received",
196 "Triggered when SAML client receives an authentication response",
197 json!({"type": "placeholder", "event_id": "saml_response_received"}),
198 ),
199 ]
200 }
201 fn stack_name(&self) -> &'static str {
202 "ETH>IP>TCP>HTTP>SAML"
203 }
204 fn keywords(&self) -> Vec<&'static str> {
205 vec![
206 "saml",
207 "saml client",
208 "connect to saml",
209 "sso",
210 "single sign-on",
211 ]
212 }
213 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
214 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
215
216 ProtocolMetadataV2::builder()
217 .state(DevelopmentState::Experimental)
218 .implementation("Custom SAML SP client with XML parsing")
219 .llm_control("SSO initiation, assertion validation, attribute extraction")
220 .e2e_testing("SAML test IdP or simplesamlphp")
221 .build()
222 }
223 fn description(&self) -> &'static str {
224 "SAML Service Provider client for federated authentication"
225 }
226 fn example_prompt(&self) -> &'static str {
227 "Connect to SAML IdP at https://idp.example.com/saml and authenticate user"
228 }
229 fn group_name(&self) -> &'static str {
230 "Authentication"
231 }
232 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
233 use crate::llm::actions::StartupExamples;
234 use serde_json::json;
235
236 StartupExamples::new(
237 json!({
239 "type": "open_client",
240 "remote_addr": "https://idp.example.com/saml/sso",
241 "base_stack": "saml",
242 "instruction": "Initiate SAML SSO authentication with the Identity Provider",
243 "startup_params": {
244 "entity_id": "https://myapp.example.com/saml/sp",
245 "acs_url": "https://myapp.example.com/saml/acs"
246 }
247 }),
248 json!({
250 "type": "open_client",
251 "remote_addr": "https://idp.example.com/saml/sso",
252 "base_stack": "saml",
253 "startup_params": {
254 "entity_id": "https://myapp.example.com/saml/sp"
255 },
256 "event_handlers": [{
257 "event_pattern": "saml_connected",
258 "handler": {
259 "type": "script",
260 "language": "python",
261 "code": "<saml_client_handler>"
262 }
263 }]
264 }),
265 json!({
267 "type": "open_client",
268 "remote_addr": "https://idp.example.com/saml/sso",
269 "base_stack": "saml",
270 "startup_params": {
271 "entity_id": "https://myapp.example.com/saml/sp"
272 },
273 "event_handlers": [{
274 "event_pattern": "saml_connected",
275 "handler": {
276 "type": "static",
277 "actions": [{
278 "type": "initiate_sso",
279 "relay_state": "/dashboard",
280 "force_authn": false
281 }]
282 }
283 }]
284 }),
285 )
286 }
287}
288
289impl Client for SamlClientProtocol {
291 fn connect(
292 &self,
293 ctx: crate::protocol::ConnectContext,
294 ) -> std::pin::Pin<
295 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
296 > {
297 Box::pin(async move {
298 use crate::client::saml::SamlClient;
299 SamlClient::connect_with_llm_actions(
300 ctx.remote_addr,
301 ctx.llm_client,
302 ctx.state,
303 ctx.status_tx,
304 ctx.client_id,
305 ctx.startup_params,
306 )
307 .await
308 })
309 }
310 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
311 let action_type = action
312 .get("type")
313 .and_then(|v| v.as_str())
314 .context("Missing 'type' field in action")?;
315
316 match action_type {
317 "initiate_sso" => {
318 let relay_state = action
319 .get("relay_state")
320 .and_then(|v| v.as_str())
321 .map(|s| s.to_string());
322
323 let force_authn = action
324 .get("force_authn")
325 .and_then(|v| v.as_bool())
326 .unwrap_or(false);
327
328 Ok(ClientActionResult::Custom {
329 name: "saml_initiate_sso".to_string(),
330 data: json!({
331 "relay_state": relay_state,
332 "force_authn": force_authn,
333 }),
334 })
335 }
336 "validate_assertion" => {
337 let saml_response = action
338 .get("saml_response")
339 .and_then(|v| v.as_str())
340 .context("Missing 'saml_response' field")?
341 .to_string();
342
343 Ok(ClientActionResult::Custom {
344 name: "saml_validate_assertion".to_string(),
345 data: json!({
346 "saml_response": saml_response,
347 }),
348 })
349 }
350 "parse_assertion" => {
351 let response_xml = action
352 .get("response_xml")
353 .and_then(|v| v.as_str())
354 .context("Missing 'response_xml' field")?
355 .to_string();
356
357 Ok(ClientActionResult::Custom {
358 name: "saml_parse_assertion".to_string(),
359 data: json!({
360 "response_xml": response_xml,
361 }),
362 })
363 }
364 "disconnect" => Ok(ClientActionResult::Disconnect),
365 _ => Err(anyhow::anyhow!(
366 "Unknown SAML client action: {}",
367 action_type
368 )),
369 }
370 }
371}