netget/server/saml_sp/mod.rs
1//! SAML Service Provider (SP) server implementation
2//!
3//! Serves the SP side of SAML 2.0 Web Browser SSO: `/login` starts SP-initiated SSO, `/acs`
4//! receives the IDP's SAMLResponse, `/metadata` describes the SP. The model reads the
5//! assertion and decides who the user is.
6//!
7//! **NetGet verifies nothing.** The SAMLResponse body is passed to the model as text; no XML
8//! signature is checked (there is no key to check it against), and neither are issuer,
9//! audience, expiry or assertion-ID replay. This is a simulator for exercising IDPs and for
10//! honeypots — never an access-control boundary.
11
12pub mod actions;
13
14use std::convert::Infallible;
15use std::net::SocketAddr;
16use std::sync::Arc;
17
18use bytes::Bytes;
19use http_body_util::{BodyExt, Full};
20use hyper::body::{Body, Incoming};
21use hyper::server::conn::http1;
22use hyper::service::service_fn;
23use hyper::{Request, Response, StatusCode};
24use hyper_util::rt::TokioIo;
25use tokio::io::AsyncWriteExt;
26use tokio::sync::mpsc;
27use tracing::{debug, error, trace, warn};
28
29use crate::llm::action_helper::call_llm;
30use crate::llm::ollama_client::OllamaClient;
31use crate::logging::emit::Log;
32use crate::protocol::Event;
33use crate::server::connection::ConnectionId;
34use crate::server::SamlSpProtocol;
35use crate::state::app_state::AppState;
36use actions::SAML_SP_REQUEST_EVENT;
37
38/// Largest request body this server will buffer.
39///
40/// `/acs`, `/sso` and every other route here is reachable with no credential at all, and the
41/// body is handed to the model verbatim as prompt text, so the previous unbounded
42/// `req.collect()` let one anonymous POST grow the process without limit and drive an LLM
43/// call with megabytes of attacker-chosen prompt. A base64 SAMLResponse form is a few tens of
44/// KiB; 256 KiB is generous for even a large assertion with attributes.
45pub const MAX_REQUEST_BYTES: usize = 256 * 1024;
46
47/// How long a peer that has connected and produced nothing may hold a slot.
48///
49/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
50/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
51/// nothing. That state carries no protocol yet, which is why this number is the same across
52/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
53/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
54/// two deployed norms.
55///
56/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
57/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
58/// defaulted duration to `None` when no timer is present and applies no deadline at all.
59const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
60
61/// How long a connection with nothing outstanding may sit idle between requests.
62///
63/// These endpoints are reached two ways and the two pull in opposite directions. A **browser**
64/// does one redirect round and never comes back on that connection, which is the case Apache's
65/// `KeepAliveTimeout` default of 5s is tuned for. A **relying party's back-channel** client
66/// (token exchange, introspection, a JWKS or metadata fetch) reuses a pooled connection, which
67/// is the case nginx's 75-second `keepalive_timeout` is tuned for. 60s is comfortably past any
68/// pooled back-channel round trip while not letting a browser that navigated away hold a slot
69/// for minutes. Nothing here streams or long-polls, so no legitimate request sits open.
70///
71/// **The five-minute numbers SAML and OIDC do quote are not this number.** An assertion's
72/// `NotOnOrAfter` and an `id_token`'s `exp` bound how long a *credential* may be presented, not
73/// how long a socket may be silent; copying one into the other is the mistake this comment
74/// exists to prevent. And a request still being answered is not silence either: the watchdog
75/// reads [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which
76/// reports a connection with work in flight — a model round-trip, or an event a `manual` rule
77/// parked for a human — as not idle at all.
78const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
79
80/// Concurrent connections this server admits.
81///
82/// The shared default. Each admitted connection may buffer one body of up to
83/// [`MAX_REQUEST_BYTES`] (256 KiB), so the cap is what turns that per-connection bound into a total
84/// one and holds the worst case well inside the ~1 GiB ceiling netget's HTTP family is held
85/// to. A protocol only declares a smaller number when its per-connection cost is larger.
86const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
87
88/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
89///
90/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
91/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
92/// error reaches the wire (see `crate::utils::wire_failure`).
93const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
94 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
95
96/// Narrow a model-supplied HTTP status to `u16` without wrapping.
97///
98/// `status as u16` on a `u64` truncates, and the truncation is the dangerous direction here:
99/// `65736` becomes `200`, and a 2xx is the only thing an SP treats as a completed sign-in. A
100/// status the model cannot have meant must not become the one that admits someone.
101fn status_or(value: Option<&serde_json::Value>, default: u16) -> u16 {
102 match value.and_then(|v| v.as_u64()) {
103 Some(raw) => u16::try_from(raw)
104 .ok()
105 .filter(|s| (100..=599).contains(s))
106 .unwrap_or_else(|| {
107 warn!("SAML SP: ignoring out-of-range status {raw}, using {default}");
108 default
109 }),
110 None => default,
111 }
112}
113
114/// Build a response from parts that came from the model, without ever panicking.
115///
116/// `status` and every response header are model output (`send_error_response` documents
117/// `status_code`, and `process_assertion` puts a model-supplied user id into `Set-Cookie`).
118/// The previous code did `Response::builder().status(status as u16)…body(..).unwrap()`, so a
119/// `status_code` of 1000 — or a header value containing CR/LF — panicked inside the
120/// connection task. Local copy of `http_common::handler::build_safe_response`, which the
121/// `saml-sp` feature cannot reach because `http_common` is gated on `feature = "http"`.
122fn build_safe_response(
123 status: u16,
124 headers: impl IntoIterator<Item = (String, String)>,
125 body: String,
126) -> Response<Full<Bytes>> {
127 let status_code = StatusCode::from_u16(status).unwrap_or_else(|_| {
128 error!("SAML SP: invalid HTTP status {status}, sending 500 instead");
129 StatusCode::INTERNAL_SERVER_ERROR
130 });
131
132 let mut builder = Response::builder().status(status_code);
133 for (name, value) in headers {
134 match (
135 hyper::header::HeaderName::from_bytes(name.as_bytes()),
136 hyper::header::HeaderValue::from_str(&value),
137 ) {
138 (Ok(n), Ok(v)) => builder = builder.header(n, v),
139 _ => warn!("SAML SP: dropping invalid response header {name:?}"),
140 }
141 }
142
143 builder
144 .body(Full::new(Bytes::from(body)))
145 .unwrap_or_else(|e| {
146 error!("SAML SP: failed to build response ({e}), sending bare 500");
147 let mut fallback =
148 Response::new(Full::new(Bytes::from_static(b"Internal Server Error")));
149 *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
150 fallback
151 })
152}
153
154/// SAML SP server that delegates authentication and assertion generation to LLM
155pub struct SamlSpServer;
156
157impl SamlSpServer {
158 /// Spawn the SAML SP server with LLM-controlled authentication
159 pub async fn spawn_with_llm_actions(
160 listen_addr: SocketAddr,
161 llm_client: OllamaClient,
162 app_state: Arc<AppState>,
163 status_tx: mpsc::UnboundedSender<String>,
164 server_id: crate::state::ServerId,
165 ) -> anyhow::Result<SocketAddr> {
166 let listener =
167 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
168 let local_addr = listener.local_addr()?;
169
170 Log::new(Some(&status_tx)).info(format!("SAML SP server listening on {}", local_addr));
171
172 let protocol = Arc::new(SamlSpProtocol::new());
173
174 // Spawn server loop
175 let task_registrar = app_state.clone();
176 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
177 let accept_handle = tokio::spawn(async move {
178 loop {
179 match crate::server::accept_bounded::accept_bounded(
180 &listener,
181 &limiter,
182 CONNECTION_CAP_REFUSAL,
183 "SAML SP",
184 Some(&status_tx),
185 )
186 .await
187 {
188 Ok((stream, remote_addr, permit)) => {
189 let connection_id =
190 ConnectionId::new(app_state.get_next_unified_id().await);
191 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
192
193 Log::new(Some(&status_tx)).info(format!(
194 "Accepted SAML SP connection {} from {}",
195 connection_id, remote_addr
196 ));
197
198 let status_tx_for_task = status_tx.clone();
199
200 // Add connection to ServerInstance
201 use crate::state::server::{
202 ConnectionState as ServerConnectionState, ConnectionStatus,
203 ProtocolConnectionInfo,
204 };
205 let now = crate::utils::clock::Instant::now();
206 let conn_state = ServerConnectionState {
207 id: connection_id,
208 remote_addr,
209 local_addr: local_addr_conn,
210 bytes_sent: 0,
211 bytes_received: 0,
212 packets_sent: 0,
213 packets_received: 0,
214 last_activity: now,
215 status: ConnectionStatus::Active,
216 status_changed_at: now,
217 protocol_info: ProtocolConnectionInfo::empty(),
218 };
219 app_state
220 .add_connection_to_server(server_id, conn_state)
221 .await;
222 let _ = status_tx.send("__UPDATE_UI__".to_string());
223
224 let llm_client_clone = llm_client.clone();
225 let app_state_clone = app_state.clone();
226 let protocol_clone = protocol.clone();
227
228 // Spawn a task to handle this connection
229 // Tracked, not detached: stop_server must abort this task too.
230 let task_owner = app_state.clone();
231 task_owner
232 .spawn_server_task(server_id, async move {
233 let status_tx = status_tx_for_task;
234 // Held for the life of the connection, so the cap counts live
235 // clients rather than accepts.
236 let _permit = permit;
237
238 // First-byte bound, before hyper sees the socket.
239 //
240 // hyper owns every read once `serve_connection` starts, and a
241 // deadline on those reads would be wrong here rather than merely
242 // awkward: hyper keeps polling the connection for more input
243 // while a request is being answered, so such a deadline would
244 // fire in the middle of a model round-trip. `peek` waits for data
245 // without consuming it, so the request line is still there for
246 // hyper afterwards, and it bounds exactly the case that needs
247 // bounding - a peer that has connected and sent nothing at all.
248 let spoke = matches!(
249 tokio::time::timeout(
250 FIRST_BYTE_READ_TIMEOUT,
251 stream.peek(&mut [0u8; 1]),
252 )
253 .await,
254 Ok(Ok(n)) if n > 0
255 );
256
257 let io = TokioIo::new(stream);
258
259 // Clone for service_fn closure
260 let llm_for_service = llm_client_clone.clone();
261 let state_for_service = app_state_clone.clone();
262 let status_for_service = status_tx.clone();
263 let protocol_for_service = protocol_clone.clone();
264
265 // Tracks whether this connection is answering anything. A
266 // request waiting on the model, or parked for a human by a
267 // `manual` rule, holds the count above zero, so the idle watchdog
268 // below cannot close the connection the answer belongs to however
269 // long it takes - only genuine silence counts.
270 let activity = std::sync::Arc::new(
271 crate::server::accept_bounded::ConnectionActivity::new(),
272 );
273 let activity_for_service = std::sync::Arc::clone(&activity);
274
275 // Create a service that handles SAML SP requests with LLM
276 let service = service_fn(move |req: Request<Incoming>| {
277 let llm_clone = llm_for_service.clone();
278 let state_clone = state_for_service.clone();
279 let status_clone = status_for_service.clone();
280 let protocol_clone = protocol_for_service.clone();
281 let activity = std::sync::Arc::clone(&activity_for_service);
282 async move {
283 let _busy = activity.busy();
284 handle_saml_sp_request(
285 req,
286 connection_id,
287 server_id,
288 remote_addr,
289 llm_clone,
290 state_clone,
291 status_clone,
292 protocol_clone,
293 )
294 .await
295 }
296 });
297
298 // Serve HTTP/1 on this connection, bounded at both ends.
299 if !spoke {
300 Log::new(Some(&status_tx)).debug(format!(
301 "SAML SP peer {} sent nothing for {}s; closing before \
302 any request",
303 remote_addr,
304 FIRST_BYTE_READ_TIMEOUT.as_secs()
305 ));
306 } else {
307 // Keep the socket after hyper flushes the response. An oversized
308 // upload can still be sending when the 413 is ready.
309 let conn = http1::Builder::new()
310 .serve_connection(io, service)
311 .without_shutdown();
312 tokio::pin!(conn);
313 tokio::select! {
314 result = &mut conn => {
315 match result {
316 Ok(parts) => {
317 let mut stream = parts.io.into_inner();
318 // Send EOF after the response, then discard
319 // the upload tail within shared bounds.
320 let _ = stream.shutdown().await;
321 crate::server::accept_bounded::drain_after_response(&mut stream).await;
322 }
323 Err(err) => error!(
324 "Error serving SAML SP connection \
325 {}: {}",
326 connection_id,
327 err
328 ),
329 }
330 }
331 _ = crate::server::accept_bounded::watch_idle(
332 std::sync::Arc::clone(&activity),
333 IDLE_BETWEEN_REQUESTS_TIMEOUT,
334 ) => {
335 Log::new(Some(&status_tx)).debug(format!(
336 "SAML SP connection {} idle for {}s; closing",
337 connection_id,
338 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
339 ));
340 }
341 }
342 }
343
344 // Remove connection when done
345 debug!("SAML SP connection {} closed", connection_id);
346 app_state_clone
347 .remove_connection_from_server(server_id, connection_id)
348 .await;
349 let _ = status_tx.send("__UPDATE_UI__".to_string());
350 })
351 .await;
352 }
353 Err(e) => {
354 Log::new(Some(&status_tx))
355 .error(format!("Failed to accept SAML SP connection: {}", e));
356 }
357 }
358 }
359 });
360
361 // Register the accept loop so stop_server can abort it and release the port.
362 task_registrar
363 .register_server_task(server_id, accept_handle)
364 .await;
365
366 Ok(local_addr)
367 }
368}
369
370/// Handle a SAML SP request with LLM decision making
371async fn handle_saml_sp_request(
372 req: Request<Incoming>,
373 connection_id: ConnectionId,
374 server_id: crate::state::ServerId,
375 remote_addr: SocketAddr,
376 llm_client: OllamaClient,
377 app_state: Arc<AppState>,
378 status_tx: mpsc::UnboundedSender<String>,
379 protocol: Arc<SamlSpProtocol>,
380) -> Result<Response<Full<Bytes>>, Infallible> {
381 let method = req.method().clone();
382 let path = req.uri().path().to_string();
383 let query = req.uri().query().map(|q| q.to_string());
384
385 Log::new(Some(&status_tx)).debug(format!("SAML SP {} {} from {}", method, path, remote_addr));
386
387 // Extract headers
388 let headers: Vec<(String, String)> = req
389 .headers()
390 .iter()
391 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
392 .collect();
393
394 // Read request body
395 // Bounded, and refused rather than truncated: a cut-off SAMLResponse would reach the
396 // model as a well-formed request whose assertion happened to end early.
397 let body_bytes = match http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BYTES)
398 .collect()
399 .await
400 {
401 Ok(collected) => collected.to_bytes().to_vec(),
402 Err(e) => {
403 Log::new(Some(&status_tx)).warn(format!(
404 "SAML SP {} {} decision=fail_closed_body_rejected (limit {} bytes): {}",
405 method, path, MAX_REQUEST_BYTES, e
406 ));
407 return Ok(build_safe_response(
408 413,
409 [(
410 "content-type".to_string(),
411 "text/plain; charset=utf-8".to_string(),
412 )],
413 "request body too large".to_string(),
414 ));
415 }
416 };
417
418 // TRACE level for full payloads
419 if !body_bytes.is_empty() {
420 trace!("SAML SP request body: {} bytes", body_bytes.len());
421 if let Ok(body_str) = String::from_utf8(body_bytes.clone()) {
422 trace!("SAML SP request body content: {}", body_str);
423 }
424 }
425
426 // Update connection stats
427 app_state
428 .update_connection_stats(
429 server_id,
430 connection_id,
431 Some(body_bytes.len() as u64),
432 None,
433 None,
434 None,
435 )
436 .await;
437
438 // Build event for LLM
439 let event = Event::new(
440 &SAML_SP_REQUEST_EVENT,
441 serde_json::json!({
442 "method": method.to_string(),
443 "path": path.clone(),
444 "query": query,
445 "headers": headers,
446 "body": if body_bytes.is_empty() {
447 serde_json::Value::Null
448 } else if let Ok(body_str) = String::from_utf8(body_bytes.clone()) {
449 serde_json::Value::String(body_str)
450 } else {
451 // Never base64 in event data: a model cannot decode it, and this body is
452 // always text on a working SAML binding (the assertion's own base64 arrives
453 // inside a urlencoded form field). Say what happened instead.
454 serde_json::Value::String(format!(
455 "<{} bytes of non-UTF-8 data; not a SAML binding this server understands>",
456 body_bytes.len()
457 ))
458 },
459 "client_ip": remote_addr.ip().to_string(),
460 }),
461 );
462
463 // Call LLM for decision
464 debug!("Calling LLM for SAML SP request decision");
465 let action_result = call_llm(
466 &llm_client,
467 &app_state,
468 server_id,
469 Some(connection_id),
470 &event,
471 protocol.as_ref(),
472 )
473 .await;
474
475 // Execute actions and build response
476 let response = match action_result {
477 Ok(result) => {
478 // Three outcomes have to stay distinguishable in the log, because they mean very
479 // different things to an operator: the model refused (`decision=model_reject`),
480 // the model said nothing usable (`decision=fail_closed_no_answer`), and the
481 // backend erred (`decision=fail_closed_llm_error`, in the `Err` arm below).
482 //
483 // The default status used to be 200: a model that answered only with a
484 // common/memory action, or whose protocol result was not a parseable
485 // `Output`, produced an empty `200 OK`. For an SP that is a permissive
486 // default on a no-answer path — it is not a sign-in (no `Set-Cookie`), but it
487 // tells the browser the request succeeded. There is no default now; a
488 // response is emitted only if a usable `Output` was actually seen.
489 use crate::llm::actions::protocol_trait::ActionResult;
490
491 /// Flatten `Multiple` so an `Output` wrapped in one is not silently dropped.
492 fn collect_outputs(result: ActionResult, out: &mut Vec<Vec<u8>>) {
493 match result {
494 ActionResult::Output(data) => out.push(data),
495 ActionResult::Multiple(inner) => {
496 for r in inner {
497 collect_outputs(r, out);
498 }
499 }
500 _ => {}
501 }
502 }
503
504 let mut outputs = Vec::new();
505 for protocol_result in result.protocol_results {
506 collect_outputs(protocol_result, &mut outputs);
507 }
508
509 let mut answer: Option<(u16, std::collections::HashMap<String, String>, String)> = None;
510 for output_data in outputs {
511 let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(&output_data)
512 else {
513 warn!(
514 "SAML SP: ignoring non-JSON action output ({} bytes)",
515 output_data.len()
516 );
517 continue;
518 };
519 let (status_code, response_headers, response_body) =
520 answer.get_or_insert_with(|| {
521 (200u16, std::collections::HashMap::new(), String::new())
522 });
523 if json_value.get("status").is_some() {
524 *status_code = status_or(json_value.get("status"), 200);
525 }
526 if let Some(headers_obj) = json_value.get("headers").and_then(|v| v.as_object()) {
527 for (k, v) in headers_obj {
528 if let Some(v_str) = v.as_str() {
529 response_headers.insert(k.clone(), v_str.to_string());
530 }
531 }
532 }
533 if let Some(body) = json_value.get("body").and_then(|v| v.as_str()) {
534 *response_body = body.to_string();
535 }
536 }
537
538 match answer {
539 Some((status_code, response_headers, response_body)) => {
540 // A model-chosen 4xx is `send_error_response` — a real refusal, not a
541 // netget failure. Tag it separately so an operator can tell a denial
542 // from an outage.
543 let decision = if status_code >= 400 {
544 "model_reject"
545 } else {
546 "model_answer"
547 };
548 debug!(
549 "SAML SP {} {} from {} decision={} status={}",
550 method, path, remote_addr, decision, status_code
551 );
552 build_safe_response(status_code, response_headers, response_body)
553 }
554 None => {
555 // Either no actions at all, or actions that carried no usable HTTP
556 // response. Both are "the model answered nothing" — fail closed with a
557 // 500 and a category, never a 2xx.
558 Log::new(Some(&status_tx)).warn(format!(
559 "SAML SP {} {} from {} decision=fail_closed_no_answer status=500 \
560 (no usable action output)",
561 method, path, remote_addr
562 ));
563 build_safe_response(
564 500,
565 [(
566 "content-type".to_string(),
567 "text/plain; charset=utf-8".to_string(),
568 )],
569 crate::utils::WireFailure::Unavailable
570 .prefixed_text()
571 .to_string(),
572 )
573 }
574 }
575 }
576 Err(e) => {
577 // SAML rides on HTTP here, and the failure is ours rather than the peer's, so it
578 // is a 5xx: 503 while the backend is saturated so the peer retries, 500
579 // otherwise. Critically it is not a SAML Response at all - a 2xx carrying an
580 // assertion is the only thing an SP will accept as a sign-in, and no branch on
581 // this path can produce one.
582 //
583 // The body is `WireFailure`'s `&'static str` category, never the error: the peer
584 // is an untrusted browser and the error names the backend, the model and netget's
585 // own retry machinery. The full error goes to the log and the status stream.
586 let failure = crate::utils::WireFailure::classify(&e);
587 let overloaded = failure.is_overloaded();
588 let status = if overloaded { 503 } else { 500 };
589 Log::new(Some(&status_tx)).error(format!(
590 "SAML SP {} {} from {} decision=fail_closed_llm_error status={} overload={}: {}",
591 method, path, remote_addr, status, overloaded, e
592 ));
593 let mut headers = vec![(
594 "content-type".to_string(),
595 "text/plain; charset=utf-8".to_string(),
596 )];
597 if overloaded {
598 // Tell the browser/IDP to back off rather than record a permanent fault.
599 headers.push(("retry-after".to_string(), "5".to_string()));
600 }
601 build_safe_response(status, headers, failure.prefixed_text().to_string())
602 }
603 };
604
605 // Update bytes sent
606 let response_size = response.body().size_hint().exact().unwrap_or(0);
607 app_state
608 .update_connection_stats(
609 server_id,
610 connection_id,
611 None,
612 Some(response_size),
613 None,
614 None,
615 )
616 .await;
617
618 debug!(
619 "SAML SP response: {} ({} bytes)",
620 response.status(),
621 response_size
622 );
623
624 Ok(response)
625}