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