netget/server/openid/mod.rs
1//! OpenID Connect server implementation
2//!
3//! The LLM controls all OpenID Connect endpoints and generates responses including
4//! discovery documents, authorization codes, JWT tokens, and user info.
5
6pub mod actions;
7
8use std::collections::HashMap;
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::Incoming;
16use hyper::server::conn::http1;
17use hyper::service::service_fn;
18use hyper::{Request, Response};
19use hyper_util::rt::TokioIo;
20use serde_json::json;
21use tokio::sync::mpsc;
22use tokio::sync::RwLock;
23use tracing::{debug, error, trace, warn};
24
25use crate::llm::action_helper::call_llm;
26use crate::llm::ollama_client::OllamaClient;
27use crate::logging::emit::Log;
28use crate::protocol::Event;
29use crate::server::connection::ConnectionId;
30use crate::server::openid::actions::OpenIdProtocol;
31use crate::state::app_state::AppState;
32
33/// Largest request body this server will buffer.
34///
35/// Every endpoint here is reachable before any credential is checked, and the body is parsed
36/// and handed to the model as prompt text, so the previous unbounded `collect()` let one
37/// anonymous POST grow the process without limit. An OIDC form body is a handful of short
38/// parameters; 64 KiB is far past anything a conforming relying party sends.
39pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
40
41/// How long a peer that has connected and produced nothing may hold a slot.
42///
43/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
44/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
45/// nothing. That state carries no protocol yet, which is why this number is the same across
46/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
47/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
48/// two deployed norms.
49///
50/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
51/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
52/// defaulted duration to `None` when no timer is present and applies no deadline at all.
53const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
54
55/// How long a connection with nothing outstanding may sit idle between requests.
56///
57/// These endpoints are reached two ways and the two pull in opposite directions. A **browser**
58/// does one redirect round and never comes back on that connection, which is the case Apache's
59/// `KeepAliveTimeout` default of 5s is tuned for. A **relying party's back-channel** client
60/// (token exchange, introspection, a JWKS or metadata fetch) reuses a pooled connection, which
61/// is the case nginx's 75-second `keepalive_timeout` is tuned for. 60s is comfortably past any
62/// pooled back-channel round trip while not letting a browser that navigated away hold a slot
63/// for minutes. Nothing here streams or long-polls, so no legitimate request sits open.
64///
65/// **The five-minute numbers SAML and OIDC do quote are not this number.** An assertion's
66/// `NotOnOrAfter` and an `id_token`'s `exp` bound how long a *credential* may be presented, not
67/// how long a socket may be silent; copying one into the other is the mistake this comment
68/// exists to prevent. And a request still being answered is not silence either: the watchdog
69/// reads [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which
70/// reports a connection with work in flight — a model round-trip, or an event a `manual` rule
71/// parked for a human — as not idle at all.
72const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
73
74/// Concurrent connections this server admits.
75///
76/// The shared default. Each admitted connection may buffer one body of up to
77/// [`MAX_REQUEST_BYTES`] (64 KiB), so the cap is what turns that per-connection bound into a total
78/// one and holds the worst case well inside the ~1 GiB ceiling netget's HTTP family is held
79/// to. A protocol only declares a smaller number when its per-connection cost is larger.
80const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
81
82/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
83///
84/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
85/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
86/// error reaches the wire (see `crate::utils::wire_failure`).
87const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
88 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
89
90/// Narrow a model-supplied HTTP status to `u16` without wrapping.
91///
92/// `status as u16` on a `u64` truncates, and the truncation is the dangerous direction:
93/// `65736` becomes `200`, so a `send_error_response` the model meant as a refusal arrives at
94/// the relying party as the status it reads as success.
95fn status_or(value: Option<&serde_json::Value>, default: u16) -> u16 {
96 match value.and_then(|v| v.as_u64()) {
97 Some(raw) => u16::try_from(raw)
98 .ok()
99 .filter(|s| (100..=599).contains(s))
100 .unwrap_or_else(|| {
101 warn!("OpenID: ignoring out-of-range status_code {raw}, using {default}");
102 default
103 }),
104 None => default,
105 }
106}
107
108/// OpenID Connect provider state
109pub struct OpenIdState {
110 /// Issuer URL
111 pub issuer: Option<String>,
112 /// Supported OAuth scopes
113 pub supported_scopes: Vec<String>,
114}
115
116impl OpenIdState {
117 pub fn new() -> Self {
118 Self {
119 issuer: None,
120 supported_scopes: vec![
121 "openid".to_string(),
122 "profile".to_string(),
123 "email".to_string(),
124 ],
125 }
126 }
127}
128
129/// Determine OIDC endpoint type from request path
130fn classify_endpoint(path: &str) -> &'static str {
131 match path {
132 "/.well-known/openid-configuration" => "discovery",
133 "/authorize" => "authorization",
134 "/token" => "token",
135 "/userinfo" => "userinfo",
136 "/jwks.json" | "/jwks" => "jwks",
137 _ => "unknown",
138 }
139}
140
141/// Parse `application/x-www-form-urlencoded` data (query string or POST body).
142///
143/// `+` means space in this encoding; the previous version left it literal, so a
144/// `scope=openid+profile` body reached the model as `openid+profile`. A pair whose key or
145/// value is not valid percent-encoding is skipped rather than turned into an empty-string
146/// key, which previously made several malformed pairs overwrite one another.
147fn parse_urlencoded(input: &str) -> HashMap<String, String> {
148 let mut params = HashMap::new();
149 for pair in input.split('&') {
150 let Some((key, value)) = pair.split_once('=') else {
151 continue;
152 };
153 let (key, value) = (key.replace('+', " "), value.replace('+', " "));
154 let (Ok(key), Ok(value)) = (urlencoding::decode(&key), urlencoding::decode(&value)) else {
155 debug!("OpenID: skipping malformed urlencoded parameter {pair:?}");
156 continue;
157 };
158 params.insert(key.into_owned(), value.into_owned());
159 }
160 params
161}
162
163/// Build a response from parts that came from the model, without ever panicking.
164///
165/// `handle_llm_response` used to end in `.body(..).unwrap()` while feeding the builder a
166/// `location` header assembled from the model's `redirect_uri`. hyper rejects a header
167/// value containing CR/LF, `.body()` then returns `Err`, and the `unwrap()` took down the
168/// connection task. Local copy of `http_common::handler::build_safe_response`, which the
169/// `openid` feature cannot reach because `http_common` is gated on `feature = "http"`.
170fn build_safe_response(
171 status: u16,
172 headers: impl IntoIterator<Item = (String, String)>,
173 body: String,
174) -> Response<Full<Bytes>> {
175 let status_code = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
176 error!("OpenID: invalid HTTP status {status}, sending 500 instead");
177 hyper::StatusCode::INTERNAL_SERVER_ERROR
178 });
179
180 let mut builder = Response::builder().status(status_code);
181 for (name, value) in headers {
182 match (
183 hyper::header::HeaderName::from_bytes(name.as_bytes()),
184 hyper::header::HeaderValue::from_str(&value),
185 ) {
186 (Ok(n), Ok(v)) => builder = builder.header(n, v),
187 _ => warn!("OpenID: dropping invalid response header {name:?}"),
188 }
189 }
190
191 builder
192 .body(Full::new(Bytes::from(body)))
193 .unwrap_or_else(|e| {
194 error!("OpenID: failed to build response ({e}), sending bare 500");
195 let mut fallback = Response::new(Full::new(Bytes::from_static(
196 br#"{"error":"server_error"}"#.as_slice(),
197 )));
198 *fallback.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR;
199 fallback
200 })
201}
202
203/// Handle LLM response and process actions
204async fn handle_llm_response(
205 execution_result: crate::llm::actions::executor::ExecutionResult,
206 status_tx: mpsc::UnboundedSender<String>,
207 method: String,
208 path: String,
209 openid_state: Arc<RwLock<OpenIdState>>,
210) -> Result<Response<Full<Bytes>>, Infallible> {
211 debug!("LLM OpenID response received");
212
213 // Display messages
214 for msg in execution_result.messages {
215 let _ = status_tx.send(msg);
216 }
217
218 // Default response
219 let mut status_code = 200;
220 let mut response_headers = HashMap::new();
221 let mut response_body = String::new();
222 let mut redirect_location: Option<String> = None;
223
224 // Process protocol results
225 for protocol_result in execution_result.protocol_results {
226 match protocol_result {
227 crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } => {
228 match name.as_str() {
229 "send_discovery_document" => {
230 // Build OpenID Connect discovery document
231 let mut discovery = json!({
232 "issuer": data["issuer"],
233 "authorization_endpoint": data["authorization_endpoint"],
234 "token_endpoint": data["token_endpoint"],
235 "userinfo_endpoint": data["userinfo_endpoint"],
236 "jwks_uri": data["jwks_uri"],
237 "response_types_supported": data
238 .get("supported_response_types")
239 .filter(|v| !v.is_null())
240 .cloned()
241 .unwrap_or(json!(["code", "id_token", "token id_token"])),
242 "subject_types_supported": ["public"],
243 // Advertisement only — NetGet signs nothing. The model chooses what
244 // to claim here so it can stay consistent with the id_token strings
245 // and JWKS it serves; RS256 stays the default for compatibility with
246 // relying parties that reject "none".
247 "id_token_signing_alg_values_supported": data
248 .get("id_token_signing_alg_values_supported")
249 .filter(|v| !v.is_null())
250 .cloned()
251 .unwrap_or(json!(["RS256"])),
252 });
253
254 if let Some(scopes) = data.get("supported_scopes").filter(|v| !v.is_null())
255 {
256 discovery["scopes_supported"] = scopes.clone();
257 }
258
259 response_body = serde_json::to_string_pretty(&discovery)
260 .unwrap_or_else(|_| discovery.to_string());
261 response_headers
262 .insert("content-type".to_string(), "application/json".to_string());
263 status_code = 200;
264 }
265 "send_authorization_response" => {
266 // A redirect carrying neither a grant nor an error is not an answer.
267 // Falling through with an empty parameter list produced a bare 302 to
268 // the client's callback, which set `redirect_location` and so passed
269 // the fail-closed check below while saying nothing at all.
270 if !["code", "error", "id_token", "access_token"]
271 .iter()
272 .any(|k| data.get(*k).and_then(|v| v.as_str()).is_some())
273 {
274 Log::new(Some(&status_tx)).warn(format!(
275 "OpenID {} {} decision=fail_closed_empty_authorization: \
276 send_authorization_response carried no code, error or token",
277 method, path
278 ));
279 continue;
280 }
281 // Build redirect URL with query parameters
282 let redirect_uri = data["redirect_uri"].as_str().unwrap_or("");
283 let mut redirect_url = redirect_uri.to_string();
284 let mut params = Vec::new();
285
286 if let Some(code) = data.get("code").and_then(|v| v.as_str()) {
287 params.push(format!("code={}", urlencoding::encode(code)));
288 }
289 if let Some(state) = data.get("state").and_then(|v| v.as_str()) {
290 params.push(format!("state={}", urlencoding::encode(state)));
291 }
292 if let Some(error) = data.get("error").and_then(|v| v.as_str()) {
293 params.push(format!("error={}", urlencoding::encode(error)));
294 }
295 if let Some(error_desc) =
296 data.get("error_description").and_then(|v| v.as_str())
297 {
298 params.push(format!(
299 "error_description={}",
300 urlencoding::encode(error_desc)
301 ));
302 }
303
304 if !params.is_empty() {
305 let separator = if redirect_url.contains('?') { "&" } else { "?" };
306 redirect_url =
307 format!("{}{}{}", redirect_url, separator, params.join("&"));
308 }
309
310 redirect_location = Some(redirect_url.clone());
311 status_code = 302;
312 response_headers.insert("location".to_string(), redirect_url);
313 }
314 "send_token_response" => {
315 // Build OAuth token response
316 let mut token_response = json!({
317 "access_token": data["access_token"],
318 "token_type": data.get("token_type").cloned().unwrap_or(json!("Bearer")),
319 });
320
321 if let Some(id_token) = data.get("id_token") {
322 token_response["id_token"] = id_token.clone();
323 }
324 if let Some(refresh_token) = data.get("refresh_token") {
325 token_response["refresh_token"] = refresh_token.clone();
326 }
327 if let Some(expires_in) = data.get("expires_in") {
328 token_response["expires_in"] = expires_in.clone();
329 }
330 if let Some(scope) = data.get("scope") {
331 token_response["scope"] = scope.clone();
332 }
333
334 response_body = token_response.to_string();
335 response_headers
336 .insert("content-type".to_string(), "application/json".to_string());
337 response_headers
338 .insert("cache-control".to_string(), "no-store".to_string());
339 response_headers.insert("pragma".to_string(), "no-cache".to_string());
340 status_code = 200;
341 }
342 "send_userinfo_response" => {
343 // Build userinfo response
344 let mut userinfo = json!({
345 "sub": data["sub"],
346 });
347
348 if let Some(name) = data.get("name") {
349 userinfo["name"] = name.clone();
350 }
351 if let Some(email) = data.get("email") {
352 userinfo["email"] = email.clone();
353 }
354 if let Some(email_verified) = data.get("email_verified") {
355 userinfo["email_verified"] = email_verified.clone();
356 }
357 if let Some(picture) = data.get("picture") {
358 userinfo["picture"] = picture.clone();
359 }
360 if let Some(additional_claims) =
361 data.get("additional_claims").and_then(|v| v.as_object())
362 {
363 for (k, v) in additional_claims {
364 userinfo[k] = v.clone();
365 }
366 }
367
368 response_body = userinfo.to_string();
369 response_headers
370 .insert("content-type".to_string(), "application/json".to_string());
371 status_code = 200;
372 }
373 "send_jwks_response" => {
374 // Build JWKS response
375 let jwks = json!({
376 "keys": data.get("keys").cloned().unwrap_or(json!([]))
377 });
378
379 response_body = jwks.to_string();
380 response_headers
381 .insert("content-type".to_string(), "application/json".to_string());
382 status_code = 200;
383 }
384 "send_error_response" => {
385 // The model refusing is a real answer, and it is logged with its own
386 // `decision=` token so an operator can tell a deliberate denial apart
387 // from the model saying nothing and from the backend erroring.
388 Log::new(Some(&status_tx)).info(format!(
389 "OpenID {} {} decision=model_reject error={}",
390 method,
391 path,
392 data.get("error").and_then(|v| v.as_str()).unwrap_or("?")
393 ));
394 // Build OAuth error response
395 let error_response = json!({
396 "error": data["error"],
397 "error_description": data.get("error_description").cloned().unwrap_or(json!("")),
398 });
399
400 response_body = error_response.to_string();
401 response_headers
402 .insert("content-type".to_string(), "application/json".to_string());
403 status_code = status_or(data.get("status_code"), 400);
404 }
405 // The model reconfiguring the provider mid-flight. This used to fall into
406 // the `_` arm below: `configure_provider` was advertised, executed, and
407 // then discarded, so the issuer it set was never seen again. It produces
408 // no HTTP response of its own — deliberately, so a request answered with
409 // *only* a configure_provider still falls through to the fail-closed 500
410 // rather than an empty 200.
411 "configure_provider" => {
412 let mut state = openid_state.write().await;
413 if let Some(issuer) = data.get("issuer").and_then(|v| v.as_str()) {
414 Log::new(Some(&status_tx))
415 .info(format!("OpenID issuer set to {issuer}"));
416 state.issuer = Some(issuer.to_string());
417 }
418 if let Some(scopes) =
419 data.get("supported_scopes").and_then(|v| v.as_array())
420 {
421 let scopes: Vec<String> = scopes
422 .iter()
423 .filter_map(|v| v.as_str().map(|s| s.to_string()))
424 .collect();
425 if !scopes.is_empty() {
426 Log::new(Some(&status_tx))
427 .info(format!("OpenID scopes set to {scopes:?}"));
428 state.supported_scopes = scopes;
429 }
430 }
431 }
432 _ => {
433 debug!("Unknown custom action: {}", name);
434 }
435 }
436 }
437 _ => {}
438 }
439 }
440
441 // The model produced nothing renderable — no action at all, or only actions this handler
442 // does not know. That is not a sign-in: fail closed with RFC 6749 §5.2 `server_error`,
443 // and describe it with a category only. The peer is a relying party and netget's
444 // internals (the backend, the model, the retry machinery) are not its business; the
445 // detail belongs in the log line below.
446 if response_body.is_empty() && redirect_location.is_none() {
447 response_body = json!({
448 "error": "server_error",
449 "error_description": crate::utils::WireFailure::Unavailable.prefixed_text(),
450 })
451 .to_string();
452 response_headers.insert("content-type".to_string(), "application/json".to_string());
453 status_code = 500;
454 Log::new(Some(&status_tx)).error(format!(
455 "OpenID failing {} {} with 500 server_error decision=no_answer \
456 (the model returned no renderable OIDC response action)",
457 method, path
458 ));
459 }
460
461 // FileOnly: each send_*_response action's own log_template already reports the
462 // outcome ("-> OIDC ...") to the TUI at INFO.
463 Log::new(Some(&status_tx)).debug(format!(
464 "OpenID {} {} -> {} ({} bytes{})",
465 method,
466 path,
467 status_code,
468 response_body.len(),
469 if redirect_location.is_some() {
470 ", redirect"
471 } else {
472 ""
473 }
474 ));
475
476 Ok(build_safe_response(
477 status_code,
478 response_headers,
479 response_body,
480 ))
481}
482
483/// OpenID Connect server that uses LLM to handle all endpoints
484pub struct OpenIdServer;
485
486impl OpenIdServer {
487 /// Spawn OpenID Connect server with LLM actions
488 pub async fn spawn_with_llm_actions(
489 listen_addr: SocketAddr,
490 llm_client: OllamaClient,
491 app_state: Arc<AppState>,
492 status_tx: mpsc::UnboundedSender<String>,
493 server_id: crate::state::ServerId,
494 startup_params: Option<crate::protocol::StartupParams>,
495 ) -> anyhow::Result<SocketAddr> {
496 // Create shared OpenID state
497 let openid_state = Arc::new(RwLock::new(OpenIdState::new()));
498 let protocol = Arc::new(OpenIdProtocol::new());
499
500 // Configure from startup params if provided
501 if let Some(ref params) = startup_params {
502 let mut state = openid_state.write().await;
503
504 if let Some(issuer) = params.get_optional_string("issuer")? {
505 Log::new(Some(&status_tx)).info(format!("OpenID issuer configured: {}", issuer));
506 state.issuer = Some(issuer);
507 }
508
509 if let Some(scopes_array) = params.get_optional_array("supported_scopes")? {
510 let scopes: Vec<String> = scopes_array
511 .iter()
512 .filter_map(|v| v.as_str().map(|s| s.to_string()))
513 .collect();
514 if !scopes.is_empty() {
515 Log::new(Some(&status_tx))
516 .info(format!("OpenID scopes configured: {:?}", scopes));
517 state.supported_scopes = scopes;
518 }
519 }
520 }
521
522 Log::new(Some(&status_tx)).info("Starting OpenID Connect server...");
523
524 // Start HTTP server
525 let listener =
526 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
527 let local_addr = listener.local_addr()?;
528 Log::new(Some(&status_tx)).info(format!("OpenID server listening on {}", local_addr));
529
530 // Spawn server loop
531 let task_registrar = app_state.clone();
532 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
533 let accept_handle = tokio::spawn(async move {
534 loop {
535 match crate::server::accept_bounded::accept_bounded(
536 &listener,
537 &limiter,
538 CONNECTION_CAP_REFUSAL,
539 "OpenID",
540 Some(&status_tx),
541 )
542 .await
543 {
544 Ok((stream, remote_addr, permit)) => {
545 let connection_id =
546 ConnectionId::new(app_state.get_next_unified_id().await);
547 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
548 Log::new(Some(&status_tx)).info(format!(
549 "OpenID connection {} from {}",
550 connection_id, remote_addr
551 ));
552
553 // Add connection to ServerInstance
554 use crate::state::server::{
555 ConnectionState as ServerConnectionState, ConnectionStatus,
556 ProtocolConnectionInfo,
557 };
558 let now = crate::utils::clock::Instant::now();
559 let conn_state = ServerConnectionState {
560 id: connection_id,
561 remote_addr,
562 local_addr: local_addr_conn,
563 bytes_sent: 0,
564 bytes_received: 0,
565 packets_sent: 0,
566 packets_received: 0,
567 last_activity: now,
568 status: ConnectionStatus::Active,
569 status_changed_at: now,
570 protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
571 "endpoint": None::<String>,
572 "authenticated": false,
573 })),
574 };
575 app_state
576 .add_connection_to_server(server_id, conn_state)
577 .await;
578 let _ = status_tx.send("__UPDATE_UI__".to_string());
579
580 let llm_client_clone = llm_client.clone();
581 let app_state_clone = app_state.clone();
582 let status_tx_clone = status_tx.clone();
583 let protocol_clone = protocol.clone();
584 let openid_state_clone = openid_state.clone();
585
586 // Spawn a task to handle this connection
587 // Tracked, not detached: stop_server must abort this task too.
588 let task_owner = app_state.clone();
589 task_owner
590 .spawn_server_task(server_id, async move {
591 // Held for the life of the connection, so the cap counts live
592 // clients rather than accepts.
593 let _permit = permit;
594
595 // First-byte bound, before hyper sees the socket.
596 //
597 // hyper owns every read once `serve_connection` starts, and a
598 // deadline on those reads would be wrong here rather than merely
599 // awkward: hyper keeps polling the connection for more input
600 // while a request is being answered, so such a deadline would
601 // fire in the middle of a model round-trip. `peek` waits for data
602 // without consuming it, so the request line is still there for
603 // hyper afterwards, and it bounds exactly the case that needs
604 // bounding - a peer that has connected and sent nothing at all.
605 let spoke = matches!(
606 tokio::time::timeout(
607 FIRST_BYTE_READ_TIMEOUT,
608 stream.peek(&mut [0u8; 1]),
609 )
610 .await,
611 Ok(Ok(n)) if n > 0
612 );
613
614 let io = TokioIo::new(stream);
615
616 // Clone for service closure
617 let status_for_service = status_tx_clone.clone();
618 let app_state_for_service = app_state_clone.clone();
619 let openid_state_for_service = openid_state_clone.clone();
620
621 // Tracks whether this connection is answering anything. A
622 // request waiting on the model, or parked for a human by a
623 // `manual` rule, holds the count above zero, so the idle watchdog
624 // below cannot close the connection the answer belongs to however
625 // long it takes - only genuine silence counts.
626 let activity = std::sync::Arc::new(
627 crate::server::accept_bounded::ConnectionActivity::new(),
628 );
629 let activity_for_service = std::sync::Arc::clone(&activity);
630
631 // Create a service that handles OpenID requests with LLM
632 let service = service_fn(move |req: Request<Incoming>| {
633 let llm_clone = llm_client_clone.clone();
634 let state_clone = app_state_for_service.clone();
635 let status_clone = status_for_service.clone();
636 let protocol_clone = protocol_clone.clone();
637 let openid_state_clone = openid_state_for_service.clone();
638 let activity = std::sync::Arc::clone(&activity_for_service);
639 async move {
640 let _busy = activity.busy();
641 handle_openid_request(
642 req,
643 connection_id,
644 llm_clone,
645 state_clone,
646 status_clone,
647 protocol_clone,
648 openid_state_clone,
649 server_id,
650 )
651 .await
652 }
653 });
654
655 // Serve HTTP/1 on this connection, bounded at both ends.
656 if !spoke {
657 Log::new(Some(&status_tx_clone)).debug(format!(
658 "OpenID peer {} sent nothing for {}s; closing before \
659 any request",
660 remote_addr,
661 FIRST_BYTE_READ_TIMEOUT.as_secs()
662 ));
663 } else {
664 let conn = http1::Builder::new().serve_connection(io, service);
665 tokio::pin!(conn);
666 tokio::select! {
667 result = &mut conn => {
668 if let Err(err) = result {
669 error!(
670 "Error serving OpenID connection: \
671 {:?}",
672 err
673 );
674 }
675 }
676 _ = crate::server::accept_bounded::watch_idle(
677 std::sync::Arc::clone(&activity),
678 IDLE_BETWEEN_REQUESTS_TIMEOUT,
679 ) => {
680 Log::new(Some(&status_tx_clone)).debug(format!(
681 "OpenID connection {} idle for {}s; closing",
682 connection_id,
683 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
684 ));
685 }
686 }
687 }
688
689 // Mark connection as closed
690 app_state_clone
691 .close_connection_on_server(server_id, connection_id)
692 .await;
693 Log::new(Some(&status_tx_clone))
694 .info(format!("OpenID connection {} closed", connection_id));
695 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
696 })
697 .await;
698 }
699 Err(e) => {
700 Log::new(Some(&status_tx))
701 .error(format!("Failed to accept OpenID connection: {}", e));
702 break;
703 }
704 }
705 }
706 });
707
708 task_registrar
709 .register_server_task(server_id, accept_handle)
710 .await;
711
712 Ok(local_addr)
713 }
714}
715
716/// Handle a single OpenID Connect request
717async fn handle_openid_request(
718 req: Request<Incoming>,
719 connection_id: ConnectionId,
720 llm_client: OllamaClient,
721 app_state: Arc<AppState>,
722 status_tx: mpsc::UnboundedSender<String>,
723 protocol: Arc<OpenIdProtocol>,
724 openid_state: Arc<RwLock<OpenIdState>>,
725 server_id: crate::state::ServerId,
726) -> Result<Response<Full<Bytes>>, Infallible> {
727 // Extract request details (before consuming body)
728 let method = req.method().to_string();
729 let path = req.uri().path().to_string();
730 let query_str = req.uri().query().map(|s| s.to_string());
731
732 // Classify endpoint
733 let endpoint_type = classify_endpoint(&path);
734
735 // Extract headers
736 let mut headers = HashMap::new();
737 for (name, value) in req.headers() {
738 if let Ok(value_str) = value.to_str() {
739 headers.insert(name.to_string(), value_str.to_string());
740 }
741 }
742
743 // Read body
744 // Capped at MAX_REQUEST_BYTES, and refused rather than silently emptied: substituting an
745 // empty body used to turn an over-limit POST into a well-formed request with no
746 // parameters at all, which the model then answered as one.
747 let body_bytes = match http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BYTES)
748 .collect()
749 .await
750 {
751 Ok(collected) => collected.to_bytes(),
752 Err(e) => {
753 Log::new(Some(&status_tx)).warn(format!(
754 "OpenID {} {} decision=fail_closed_body_rejected (limit {} bytes): {}",
755 method, path, MAX_REQUEST_BYTES, e
756 ));
757 return Ok(build_safe_response(
758 413,
759 [("content-type".to_string(), "application/json".to_string())],
760 json!({
761 "error": "invalid_request",
762 "error_description": "request body too large"
763 })
764 .to_string(),
765 ));
766 }
767 };
768 let body_text = String::from_utf8_lossy(&body_bytes).to_string();
769
770 // Parse query parameters
771 let query_params = query_str
772 .as_deref()
773 .map(parse_urlencoded)
774 .unwrap_or_default();
775
776 // Parse form data if Content-Type is application/x-www-form-urlencoded
777 let form_data = if headers
778 .get("content-type")
779 .map(|v| v.contains("application/x-www-form-urlencoded"))
780 .unwrap_or(false)
781 {
782 parse_urlencoded(&body_text)
783 } else {
784 HashMap::new()
785 };
786
787 // DEBUG, FileOnly: the openid_request event's own log_template already reports the
788 // request to the TUI at INFO.
789 Log::new(Some(&status_tx)).debug(format!(
790 "OpenID request: {} {} (endpoint: {}, {} bytes) from {:?}",
791 method,
792 path,
793 endpoint_type,
794 body_bytes.len(),
795 connection_id
796 ));
797
798 // TRACE: Log full request details
799 trace!("OpenID request headers:");
800 for (name, value) in &headers {
801 trace!(" {}: {}", name, value);
802 }
803 if !body_bytes.is_empty() {
804 trace!("OpenID request body: {}", body_text);
805 }
806 if !query_params.is_empty() {
807 trace!("OpenID query params: {:?}", query_params);
808 }
809 if !form_data.is_empty() {
810 trace!("OpenID form data: {:?}", form_data);
811 }
812
813 // The `issuer` / `supported_scopes` startup parameters, and anything a later
814 // `configure_provider` set, are reported to the model here. Until this existed they were
815 // parsed into `OpenIdState` and then never read by anything — the handler took the state
816 // as `_openid_state` — so both were advertised knobs that did nothing when turned.
817 let (configured_issuer, configured_scopes) = {
818 let state = openid_state.read().await;
819 (state.issuer.clone(), state.supported_scopes.clone())
820 };
821
822 // Create event for LLM
823 let event = Event::new(
824 &*crate::server::openid::actions::OPENID_REQUEST_EVENT,
825 json!({
826 "method": method,
827 "path": path,
828 "query_params": query_params,
829 "headers": headers,
830 "body": if body_text.is_empty() { "" } else { &body_text },
831 "form_data": form_data,
832 "endpoint_type": endpoint_type,
833 "configured_issuer": configured_issuer,
834 "configured_scopes": configured_scopes,
835 }),
836 );
837
838 // Call LLM to handle request
839 match call_llm(
840 &llm_client,
841 &app_state,
842 server_id,
843 Some(connection_id),
844 &event,
845 protocol.as_ref(),
846 )
847 .await
848 {
849 Ok(execution_result) => {
850 handle_llm_response(execution_result, status_tx, method, path, openid_state).await
851 }
852 Err(e) => {
853 // OpenID Connect inherits OAuth2's error codes (RFC 6749 5.2):
854 // `temporarily_unavailable` with 503 when the backend is merely saturated,
855 // `server_error` with 500 otherwise. Both are 5xx on purpose - a 4xx would tell
856 // the relying party its request was at fault and stop it retrying - and neither
857 // can carry an id_token, so no branch here can complete a sign-in.
858 let overloaded = crate::llm::is_overload_error(&e);
859 let (status, code) = if overloaded {
860 (503, "temporarily_unavailable")
861 } else {
862 (500, "server_error")
863 };
864 // `decision=llm_error` distinguishes this from `decision=no_answer` (the model
865 // answered, with nothing usable) and `decision=model_reject` (the model
866 // deliberately refused). The error itself goes here and nowhere else.
867 Log::new(Some(&status_tx)).error(format!(
868 "OpenID failing {} {} with {} {} decision=llm_error overload={}: {}",
869 method, path, status, code, overloaded, e
870 ));
871
872 Ok(build_safe_response(
873 status,
874 [("content-type".to_string(), "application/json".to_string())],
875 json!({
876 "error": code,
877 "error_description": crate::utils::WireFailure::classify(&e).prefixed_text()
878 })
879 .to_string(),
880 ))
881 }
882 }
883}