Skip to main content

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}