Skip to main content

netget/server/oauth2/
mod.rs

1//! OAuth2 authorization server implementation
2//!
3//! OAuth2 server implementing RFC 6749 (OAuth 2.0 Authorization Framework).
4//! The LLM controls authorization decisions, token generation, and client validation.
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::{Body, Incoming};
16use hyper::server::conn::http1;
17use hyper::service::service_fn;
18use hyper::{Method, Request, Response, StatusCode};
19use hyper_util::rt::TokioIo;
20use serde_json::json;
21use tokio::sync::mpsc;
22use tracing::{debug, error, info, 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::oauth2::actions::{
30    OAuth2Protocol, OAUTH2_AUTHORIZE_EVENT, OAUTH2_INTROSPECT_EVENT, OAUTH2_RESULT_KEY,
31    OAUTH2_REVOKE_EVENT, OAUTH2_TOKEN_EVENT,
32};
33use crate::state::app_state::AppState;
34
35/// Largest request body this server will buffer.
36///
37/// Every endpoint here is reachable **before** any credential is checked, and the body is
38/// parsed and handed to the model as prompt text, so an unbounded `collect()` let one
39/// anonymous POST grow the process without limit. An OAuth2 form body is a handful of short
40/// parameters; 64 KiB is far past anything a conforming client sends.
41pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
42
43/// How long a peer that has connected and produced nothing may hold a slot.
44///
45/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
46/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
47/// nothing. That state carries no protocol yet, which is why this number is the same across
48/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
49/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
50/// two deployed norms.
51///
52/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
53/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
54/// defaulted duration to `None` when no timer is present and applies no deadline at all.
55const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
56
57/// How long a connection with nothing outstanding may sit idle between requests.
58///
59/// These endpoints are reached two ways and the two pull in opposite directions. A **browser**
60/// does one redirect round and never comes back on that connection, which is the case Apache's
61/// `KeepAliveTimeout` default of 5s is tuned for. A **relying party's back-channel** client
62/// (token exchange, introspection, a JWKS or metadata fetch) reuses a pooled connection, which
63/// is the case nginx's 75-second `keepalive_timeout` is tuned for. 60s is comfortably past any
64/// pooled back-channel round trip while not letting a browser that navigated away hold a slot
65/// for minutes. Nothing here streams or long-polls, so no legitimate request sits open.
66///
67/// **The five-minute numbers SAML and OIDC do quote are not this number.** An assertion's
68/// `NotOnOrAfter` and an `id_token`'s `exp` bound how long a *credential* may be presented, not
69/// how long a socket may be silent; copying one into the other is the mistake this comment
70/// exists to prevent. And a request still being answered is not silence either: the watchdog
71/// reads [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which
72/// reports a connection with work in flight — a model round-trip, or an event a `manual` rule
73/// parked for a human — as not idle at all.
74const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
75
76/// Concurrent connections this server admits.
77///
78/// The shared default. Each admitted connection may buffer one body of up to
79/// [`MAX_REQUEST_BYTES`] (64 KiB), so the cap is what turns that per-connection bound into a total
80/// one and holds the worst case well inside the ~1 GiB ceiling netget's HTTP family is held
81/// to. A protocol only declares a smaller number when its per-connection cost is larger.
82const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
83
84/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
85///
86/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
87/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
88/// error reaches the wire (see `crate::utils::wire_failure`).
89const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
90    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
91
92/// Buffer a request body, refusing anything over [`MAX_REQUEST_BYTES`].
93///
94/// Refusing is the point: continuing with a truncated body would hand the model a
95/// well-formed request whose credentials had been cut off, and it would be answered as one.
96async fn read_body_limited(body: Incoming) -> Result<Bytes, ()> {
97    match http_body_util::Limited::new(body, MAX_REQUEST_BYTES)
98        .collect()
99        .await
100    {
101        Ok(collected) => Ok(collected.to_bytes()),
102        Err(e) => {
103            // The refusal is the protocol's, before any model call: there is no decision to
104            // make about a body the server declined to read. Every caller answers 413.
105            warn!(
106                "OAuth2 decision=refused_body_too_large: rejecting request body over \
107                 {MAX_REQUEST_BYTES} bytes: {e}"
108            );
109            Err(())
110        }
111    }
112}
113
114/// The form parameters that are safe to put in a log line or on the status stream.
115///
116/// `client_secret`, `password`, `token`, `code` and `refresh_token` are credentials. They
117/// still reach the *model*, because deciding whether they are valid is the whole job, but a
118/// `{:?}` of the whole parameter map used to copy them into `netget.log` and onto the TUI
119/// status stream, where they outlive the request and are read by anyone with the file.
120fn redact_params(params: &HashMap<String, String>) -> Vec<(&str, &str)> {
121    const SECRET: &[&str] = &[
122        "client_secret",
123        "password",
124        "token",
125        "code",
126        "refresh_token",
127        "assertion",
128    ];
129    let mut pairs: Vec<(&str, &str)> = params
130        .iter()
131        .map(|(k, v)| {
132            if SECRET.contains(&k.as_str()) {
133                (k.as_str(), if v.is_empty() { "" } else { "<redacted>" })
134            } else {
135                (k.as_str(), v.as_str())
136            }
137        })
138        .collect();
139    pairs.sort_unstable();
140    pairs
141}
142
143/// Narrow a model-supplied HTTP status to `u16` without wrapping.
144///
145/// `status as u16` on a `u64` truncates, and the truncation is the dangerous direction:
146/// `65736` becomes `200`, turning a nonsense error status into a success the client
147/// believes. Anything that is not a real status falls back to the RFC default.
148fn status_or(value: Option<&serde_json::Value>, default: u16) -> u16 {
149    match value.and_then(|v| v.as_u64()) {
150        Some(raw) => u16::try_from(raw)
151            .ok()
152            .filter(|s| (100..=599).contains(s))
153            .unwrap_or_else(|| {
154                warn!("OAuth2: ignoring out-of-range status_code {raw}, using {default}");
155                default
156            }),
157        None => default,
158    }
159}
160
161/// Build a response from parts that came from the model or from the request, without ever
162/// panicking.
163///
164/// Every `Response::builder()` chain here used to end in `.body(..).unwrap()`. That is a
165/// remotely reachable panic: `/authorize` puts the client-supplied `redirect_uri` straight
166/// into the `Location` header, and `parse_query_params` percent-decodes it first, so
167/// `?redirect_uri=http://x/%0D%0A` yields a header value containing CRLF. hyper rejects it,
168/// `.body()` returns `Err`, and the `unwrap()` kills the connection task. (Had hyper
169/// accepted it, the same input would have been response-splitting.)
170///
171/// This is a local copy of `http_common::handler::build_safe_response`, which the `oauth2`
172/// feature cannot reach because `http_common` is gated on `feature = "http"`.
173fn build_safe_response(
174    status: u16,
175    headers: impl IntoIterator<Item = (String, String)>,
176    body: String,
177) -> Response<Full<Bytes>> {
178    let status_code = StatusCode::from_u16(status).unwrap_or_else(|_| {
179        error!("OAuth2: invalid HTTP status {status}, sending 500 instead");
180        StatusCode::INTERNAL_SERVER_ERROR
181    });
182
183    let mut builder = Response::builder().status(status_code);
184    for (name, value) in headers {
185        match (
186            hyper::header::HeaderName::from_bytes(name.as_bytes()),
187            hyper::header::HeaderValue::from_str(&value),
188        ) {
189            (Ok(n), Ok(v)) => builder = builder.header(n, v),
190            _ => warn!("OAuth2: dropping invalid response header {name:?}"),
191        }
192    }
193
194    builder
195        .body(Full::new(Bytes::from(body)))
196        .unwrap_or_else(|e| {
197            error!("OAuth2: failed to build response ({e}), sending bare 500");
198            let mut fallback = Response::new(Full::new(Bytes::from_static(
199                br#"{"error":"server_error"}"#.as_slice(),
200            )));
201            *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
202            fallback
203        })
204}
205
206/// JSON response with the no-store/no-cache headers RFC 6749 §5.1 requires on token replies.
207/// The HTTP status and RFC 6749 error code for a failure of *our* backend, never the client's
208/// request.
209///
210/// RFC 6749 5.2 defines both: `temporarily_unavailable` ("the authorization server is
211/// currently unable to handle the request due to a temporary overloading or maintenance") with
212/// 503, and `server_error` ("unexpected condition") with 500. Neither is a 4xx and neither is
213/// a grant-specific code, so no client can read either as a verdict on what it sent.
214fn oauth2_backend_failure(err: &anyhow::Error) -> (u16, &'static str) {
215    if crate::llm::is_overload_error(err) {
216        (503, "temporarily_unavailable")
217    } else {
218        (500, "server_error")
219    }
220}
221
222/// The `error_description` a client may see: a category, never the error itself
223/// (`crate::utils::wire_failure`).
224fn oauth2_failure_description(err: &anyhow::Error) -> &'static str {
225    crate::utils::WireFailure::classify(err).prefixed_text()
226}
227
228/// The `decision=` token for a `call_llm` that returned `Err`.
229///
230/// A saturated backend and a dead one both refuse, and both are `fail_closed_` so the
231/// documented `grep decision=fail_closed` finds them — but only one is worth retrying, and
232/// an operator reading `netget.log` should not have to infer which from the HTTP status.
233fn llm_error_decision(err: &anyhow::Error) -> &'static str {
234    match crate::utils::WireFailure::classify(err) {
235        crate::utils::WireFailure::Overloaded => "fail_closed_llm_overloaded",
236        crate::utils::WireFailure::Unavailable => "fail_closed_llm_error",
237    }
238}
239
240fn json_response(status: u16, body: String) -> Response<Full<Bytes>> {
241    build_safe_response(
242        status,
243        [
244            ("content-type".to_string(), "application/json".to_string()),
245            ("cache-control".to_string(), "no-store".to_string()),
246            ("pragma".to_string(), "no-cache".to_string()),
247        ],
248        body,
249    )
250}
251
252/// The single JSON payload an OAuth2 executor produced, if the model emitted one.
253///
254/// `execute_action` tags each payload with [`OAUTH2_RESULT_KEY`] so the caller can tell an
255/// approval from a denial. The old code just scanned for a `code` field, which meant an
256/// `oauth2_error_response` — the model's only way to refuse — looked identical to "no
257/// action at all" and was replaced by the hardcoded success default.
258fn first_oauth2_payload(
259    results: Vec<crate::llm::ActionResult>,
260) -> Option<(String, serde_json::Value)> {
261    results.into_iter().find_map(|result| {
262        let crate::llm::ActionResult::Output(bytes) = result else {
263            return None;
264        };
265        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
266        let kind = value.get(OAUTH2_RESULT_KEY)?.as_str()?.to_string();
267        Some((kind, value))
268    })
269}
270
271/// OAuth2 authorization server
272pub struct OAuth2Server;
273
274impl OAuth2Server {
275    /// Spawn the OAuth2 server with integrated LLM actions
276    pub async fn spawn_with_llm_actions(
277        listen_addr: SocketAddr,
278        llm_client: OllamaClient,
279        app_state: Arc<AppState>,
280        status_tx: mpsc::UnboundedSender<String>,
281        server_id: crate::state::ServerId,
282    ) -> anyhow::Result<SocketAddr> {
283        let listener =
284            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
285        let local_addr = listener.local_addr()?;
286        Log::new(Some(&status_tx)).info(format!("OAuth2 server listening on {}", local_addr));
287
288        let protocol = Arc::new(OAuth2Protocol::new());
289
290        // Spawn server loop
291        let task_registrar = app_state.clone();
292        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
293        let accept_handle = tokio::spawn(async move {
294            loop {
295                match crate::server::accept_bounded::accept_bounded(
296                    &listener,
297                    &limiter,
298                    CONNECTION_CAP_REFUSAL,
299                    "OAuth2",
300                    Some(&status_tx),
301                )
302                .await
303                {
304                    Ok((stream, remote_addr, permit)) => {
305                        let connection_id =
306                            ConnectionId::new(app_state.get_next_unified_id().await);
307                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
308                        info!("OAuth2 connection {} from {}", connection_id, remote_addr);
309
310                        // Add connection to ServerInstance
311                        use crate::state::server::{
312                            ConnectionState as ServerConnectionState, ConnectionStatus,
313                            ProtocolConnectionInfo,
314                        };
315                        use serde_json::json;
316                        let now = crate::utils::clock::Instant::now();
317                        let conn_state = ServerConnectionState {
318                            id: connection_id,
319                            remote_addr,
320                            local_addr: local_addr_conn,
321                            bytes_sent: 0,
322                            bytes_received: 0,
323                            packets_sent: 0,
324                            packets_received: 0,
325                            last_activity: now,
326                            status: ConnectionStatus::Active,
327                            status_changed_at: now,
328                            protocol_info: ProtocolConnectionInfo::new(json!({
329                                "recent_requests": []
330                            })),
331                        };
332                        app_state
333                            .add_connection_to_server(server_id, conn_state)
334                            .await;
335                        let _ = status_tx.send("__UPDATE_UI__".to_string());
336
337                        let llm_client_clone = llm_client.clone();
338                        let app_state_clone = app_state.clone();
339                        let status_tx_clone = status_tx.clone();
340                        let protocol_clone = protocol.clone();
341
342                        // Spawn a task to handle this connection
343                        // Tracked, not detached: stop_server must abort this task too.
344                        let task_owner = app_state.clone();
345                        task_owner
346                            .spawn_server_task(server_id, async move {
347                                // Held for the life of the connection, so the cap counts live
348                                // clients rather than accepts.
349                                let _permit = permit;
350
351                                // First-byte bound, before hyper sees the socket.
352                                //
353                                // hyper owns every read once `serve_connection` starts, and a
354                                // deadline on those reads would be wrong here rather than merely
355                                // awkward: hyper keeps polling the connection for more input
356                                // while a request is being answered, so such a deadline would
357                                // fire in the middle of a model round-trip. `peek` waits for data
358                                // without consuming it, so the request line is still there for
359                                // hyper afterwards, and it bounds exactly the case that needs
360                                // bounding - a peer that has connected and sent nothing at all.
361                                let spoke = matches!(
362                                    tokio::time::timeout(
363                                        FIRST_BYTE_READ_TIMEOUT,
364                                        stream.peek(&mut [0u8; 1]),
365                                    )
366                                    .await,
367                                    Ok(Ok(n)) if n > 0
368                                );
369
370                                let io = TokioIo::new(stream);
371
372                                // Clone for service closure
373                                let status_for_service = status_tx_clone.clone();
374                                let app_state_for_service = app_state_clone.clone();
375
376                                // Tracks whether this connection is answering anything. A
377                                // request waiting on the model, or parked for a human by a
378                                // `manual` rule, holds the count above zero, so the idle watchdog
379                                // below cannot close the connection the answer belongs to however
380                                // long it takes - only genuine silence counts.
381                                let activity = std::sync::Arc::new(
382                                    crate::server::accept_bounded::ConnectionActivity::new(),
383                                );
384                                let activity_for_service = std::sync::Arc::clone(&activity);
385
386                                // Create a service that handles OAuth2 requests with LLM
387                                let service = service_fn(move |req: Request<Incoming>| {
388                                    let llm_clone = llm_client_clone.clone();
389                                    let state_clone = app_state_for_service.clone();
390                                    let status_clone = status_for_service.clone();
391                                    let protocol_clone = protocol_clone.clone();
392                                    let activity = std::sync::Arc::clone(&activity_for_service);
393                                    async move {
394                                        let _busy = activity.busy();
395                                        handle_oauth2_request(
396                                            req,
397                                            connection_id,
398                                            server_id,
399                                            llm_clone,
400                                            state_clone,
401                                            status_clone,
402                                            protocol_clone,
403                                        )
404                                        .await
405                                    }
406                                });
407
408                                // Serve HTTP/1 on this connection, bounded at both ends.
409                                if !spoke {
410                                    Log::new(Some(&status_tx_clone)).debug(format!(
411                                        "OAuth2 peer {} sent nothing for {}s; closing before \
412                                         any request",
413                                        remote_addr,
414                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
415                                    ));
416                                } else {
417                                    let conn = http1::Builder::new().serve_connection(io, service);
418                                    tokio::pin!(conn);
419                                    tokio::select! {
420                                        result = &mut conn => {
421                                            if let Err(err) = result {
422                                                error!(
423                                                    "Error serving OAuth2 connection: \
424                                                     {:?}",
425                                                    err
426                                                );
427                                            }
428                                        }
429                                        _ = crate::server::accept_bounded::watch_idle(
430                                            std::sync::Arc::clone(&activity),
431                                            IDLE_BETWEEN_REQUESTS_TIMEOUT,
432                                        ) => {
433                                            Log::new(Some(&status_tx_clone)).debug(format!(
434                                                "OAuth2 connection {} idle for {}s; closing",
435                                                connection_id,
436                                                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
437                                            ));
438                                        }
439                                    }
440                                }
441
442                                // Mark connection as closed
443                                app_state_clone
444                                    .close_connection_on_server(server_id, connection_id)
445                                    .await;
446                                Log::new(Some(&status_tx_clone))
447                                    .info(format!("OAuth2 connection {connection_id} closed"));
448                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
449                            })
450                            .await;
451                    }
452                    Err(e) => {
453                        error!("Failed to accept OAuth2 connection: {}", e);
454                        break;
455                    }
456                }
457            }
458        });
459
460        task_registrar
461            .register_server_task(server_id, accept_handle)
462            .await;
463
464        Ok(local_addr)
465    }
466}
467
468/// Handle a single OAuth2 request
469async fn handle_oauth2_request(
470    req: Request<Incoming>,
471    connection_id: ConnectionId,
472    server_id: crate::state::ServerId,
473    llm_client: OllamaClient,
474    app_state: Arc<AppState>,
475    status_tx: mpsc::UnboundedSender<String>,
476    protocol: Arc<OAuth2Protocol>,
477) -> Result<Response<Full<Bytes>>, Infallible> {
478    let method = req.method().clone();
479    let uri = req.uri().clone();
480    let path = uri.path();
481
482    Log::new(Some(&status_tx)).debug(format!("OAuth2 request: {} {}", method, path));
483
484    // Track request in connection info
485    app_state
486        .update_connection_stats(server_id, connection_id, None, None, Some(1), None)
487        .await;
488
489    // Route the request
490    let response = match (method.clone(), path) {
491        (Method::GET, "/authorize") | (Method::POST, "/authorize") => {
492            handle_authorize_request(
493                req,
494                connection_id,
495                server_id,
496                llm_client,
497                app_state.clone(),
498                status_tx.clone(),
499                protocol,
500            )
501            .await
502        }
503        (Method::POST, "/token") => {
504            handle_token_request(
505                req,
506                connection_id,
507                server_id,
508                llm_client,
509                app_state.clone(),
510                status_tx.clone(),
511                protocol,
512            )
513            .await
514        }
515        (Method::POST, "/introspect") => {
516            handle_introspect_request(
517                req,
518                connection_id,
519                server_id,
520                llm_client,
521                app_state.clone(),
522                status_tx.clone(),
523                protocol,
524            )
525            .await
526        }
527        (Method::POST, "/revoke") => {
528            handle_revoke_request(
529                req,
530                connection_id,
531                server_id,
532                llm_client,
533                app_state.clone(),
534                status_tx.clone(),
535                protocol,
536            )
537            .await
538        }
539        _ => {
540            debug!(
541                "OAuth2 conn={} {} {} decision=unknown_endpoint: 404, no LLM call",
542                connection_id, method, path
543            );
544            Ok(json_response(
545                404,
546                json!({
547                    "error": "invalid_request",
548                    "error_description": "Unknown endpoint"
549                })
550                .to_string(),
551            ))
552        }
553    };
554
555    // Update connection stats
556    let body_size = response
557        .as_ref()
558        .ok()
559        .map(|resp| resp.body().size_hint().exact().unwrap_or(0))
560        .unwrap_or(0);
561    app_state
562        .update_connection_stats(
563            server_id,
564            connection_id,
565            None,
566            Some(body_size),
567            None,
568            Some(1),
569        )
570        .await;
571
572    let _ = status_tx.send("__UPDATE_UI__".to_string());
573    response
574}
575
576/// Handle /authorize endpoint (RFC 6749 Section 3.1)
577async fn handle_authorize_request(
578    req: Request<Incoming>,
579    connection_id: ConnectionId,
580    server_id: crate::state::ServerId,
581    llm_client: OllamaClient,
582    app_state: Arc<AppState>,
583    status_tx: mpsc::UnboundedSender<String>,
584    protocol: Arc<OAuth2Protocol>,
585) -> Result<Response<Full<Bytes>>, Infallible> {
586    let uri = req.uri().clone();
587    let method = req.method().clone();
588
589    // Parse query parameters (GET) or form body (POST)
590    let params = if method == Method::GET {
591        parse_query_params(uri.query().unwrap_or(""))
592    } else {
593        // Read body for POST
594        match read_body_limited(req.into_body()).await {
595            Ok(body_bytes) => parse_query_params(&String::from_utf8_lossy(&body_bytes)),
596            // Fail closed: an empty parameter map would reach the model as an authorization
597            // request with no client_id at all.
598            Err(()) => {
599                return Ok(json_response(
600                    413,
601                    json!({
602                        "error": "invalid_request",
603                        "error_description": "request body too large"
604                    })
605                    .to_string(),
606                ));
607            }
608        }
609    };
610
611    Log::new(Some(&status_tx)).debug(format!(
612        "OAuth2 authorize request: {:?}",
613        redact_params(&params)
614    ));
615
616    // Create LLM event
617    let event = Event::new(
618        &OAUTH2_AUTHORIZE_EVENT,
619        json!({
620            "response_type": params.get("response_type").cloned().unwrap_or_default(),
621            "client_id": params.get("client_id").cloned().unwrap_or_default(),
622            "redirect_uri": params.get("redirect_uri").cloned().unwrap_or_default(),
623            "scope": params.get("scope").cloned().unwrap_or_default(),
624            "state": params.get("state").cloned().unwrap_or_default(),
625        }),
626    );
627
628    // Call LLM
629    match call_llm(
630        &llm_client,
631        &app_state,
632        server_id,
633        Some(connection_id),
634        &event,
635        &*protocol,
636    )
637    .await
638    {
639        Ok(execution_result) => {
640            let redirect_uri = params
641                .get("redirect_uri")
642                .cloned()
643                .unwrap_or_else(|| "urn:ietf:wg:oauth:2.0:oob".to_string());
644            let request_state = params.get("state").cloned();
645
646            match first_oauth2_payload(execution_result.protocol_results) {
647                // Approval: redirect with the code the model minted.
648                Some((kind, payload)) if kind == "authorize" => {
649                    let Some(code) = payload.get("code").and_then(|v| v.as_str()) else {
650                        Log::new(Some(&status_tx)).error(format!(
651                            "OAuth2 /authorize conn={} decision=fail_closed_bad_action: the \
652                             authorize action carried no code",
653                            connection_id
654                        ));
655                        return Ok(authorize_redirect(
656                            &redirect_uri,
657                            &[
658                                ("error", "server_error"),
659                                ("error_description", "authorization action produced no code"),
660                            ],
661                            request_state.as_deref(),
662                        ));
663                    };
664                    Log::new(Some(&status_tx)).info(format!(
665                        "OAuth2 /authorize conn={} decision=model_answer: approved, redirecting \
666                         to {}",
667                        connection_id, redirect_uri
668                    ));
669                    Ok(authorize_redirect(
670                        &redirect_uri,
671                        &[("code", code)],
672                        payload
673                            .get("state")
674                            .and_then(|v| v.as_str())
675                            .or(request_state.as_deref()),
676                    ))
677                }
678                // Denial. RFC 6749 §4.1.2.1: report it by redirecting with `error`, never by
679                // handing back a code. The previous code looked for `code`, found none, and
680                // used its `AUTH_CODE_123` default — so every refusal the model expressed was
681                // delivered to the client as a successful authorization.
682                Some((kind, payload)) if kind == "error" => {
683                    let error = payload
684                        .get("error")
685                        .and_then(|v| v.as_str())
686                        .unwrap_or("access_denied");
687                    let description = payload.get("error_description").and_then(|v| v.as_str());
688                    Log::new(Some(&status_tx)).info(format!(
689                        "OAuth2 /authorize conn={} decision=model_reject: {}",
690                        connection_id, error
691                    ));
692                    let mut pairs = vec![("error", error)];
693                    if let Some(d) = description {
694                        pairs.push(("error_description", d));
695                    }
696                    Ok(authorize_redirect(
697                        &redirect_uri,
698                        &pairs,
699                        request_state.as_deref(),
700                    ))
701                }
702                // Anything else — no action, or an action that belongs to another endpoint.
703                // Fail closed: an authorization server that mints a code when it was told
704                // nothing is worse than one that errors.
705                other => {
706                    let log = Log::new(Some(&status_tx));
707                    if let Some((kind, _)) = other {
708                        log.error(format!(
709                            "OAuth2 /authorize conn={} decision=fail_closed_bad_action: got a \
710                             '{}' result, which belongs to another endpoint",
711                            connection_id, kind
712                        ));
713                    } else {
714                        log.warn(format!(
715                            "OAuth2 /authorize conn={} decision=model_silent: no action \
716                             produced, denying",
717                            connection_id
718                        ));
719                    }
720                    Ok(authorize_redirect(
721                        &redirect_uri,
722                        &[
723                            ("error", "server_error"),
724                            (
725                                "error_description",
726                                "no authorization decision was produced",
727                            ),
728                        ],
729                        request_state.as_deref(),
730                    ))
731                }
732            }
733        }
734        Err(e) => {
735            // 4xx tells the client *it* got something wrong and the request is not worth
736            // repeating. The client got nothing wrong: our backend did. RFC 6749 5.2 pairs
737            // `server_error` with 500 and `temporarily_unavailable` with 503 for exactly this
738            // distinction, and no branch here can produce an authorization code.
739            let (status, code) = oauth2_backend_failure(&e);
740            Log::new(Some(&status_tx)).error(format!(
741                "OAuth2 /authorize conn={} decision={}: failing with {} {}: {}",
742                connection_id,
743                llm_error_decision(&e),
744                status,
745                code,
746                e
747            ));
748            Ok(json_response(
749                status,
750                json!({
751                    "error": code,
752                    "error_description": oauth2_failure_description(&e)
753                })
754                .to_string(),
755            ))
756        }
757    }
758}
759
760/// Build the 302 that answers `/authorize`, percent-encoding every value.
761///
762/// `code`, `state` and the error strings all reach here unescaped — from the model, or
763/// echoed from the client's own query string — so a value containing `&` or `=` used to
764/// inject extra parameters into the callback URL.
765fn authorize_redirect(
766    redirect_uri: &str,
767    pairs: &[(&str, &str)],
768    state: Option<&str>,
769) -> Response<Full<Bytes>> {
770    let mut query: Vec<String> = pairs
771        .iter()
772        .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
773        .collect();
774    if let Some(state) = state.filter(|s| !s.is_empty()) {
775        query.push(format!("state={}", urlencoding::encode(state)));
776    }
777
778    let separator = if redirect_uri.contains('?') { "&" } else { "?" };
779    let location = format!("{}{}{}", redirect_uri, separator, query.join("&"));
780
781    build_safe_response(302, [("location".to_string(), location)], String::new())
782}
783
784/// Handle /token endpoint (RFC 6749 Section 3.2)
785async fn handle_token_request(
786    req: Request<Incoming>,
787    connection_id: ConnectionId,
788    server_id: crate::state::ServerId,
789    llm_client: OllamaClient,
790    app_state: Arc<AppState>,
791    status_tx: mpsc::UnboundedSender<String>,
792    protocol: Arc<OAuth2Protocol>,
793) -> Result<Response<Full<Bytes>>, Infallible> {
794    // Parse form body
795    let body_bytes = match read_body_limited(req.into_body()).await {
796        Ok(bytes) => bytes,
797        Err(()) => {
798            return Ok(json_response(
799                413,
800                json!({
801                    "error": "invalid_request",
802                    "error_description": "request body too large"
803                })
804                .to_string(),
805            ));
806        }
807    };
808
809    let body_str = String::from_utf8_lossy(&body_bytes);
810    let params = parse_query_params(&body_str);
811
812    Log::new(Some(&status_tx)).debug(format!(
813        "OAuth2 token request: {:?}",
814        redact_params(&params)
815    ));
816
817    // Create LLM event
818    let event = Event::new(
819        &OAUTH2_TOKEN_EVENT,
820        json!({
821            "grant_type": params.get("grant_type").cloned().unwrap_or_default(),
822            "code": params.get("code").cloned().unwrap_or_default(),
823            "redirect_uri": params.get("redirect_uri").cloned().unwrap_or_default(),
824            "client_id": params.get("client_id").cloned().unwrap_or_default(),
825            "client_secret": params.get("client_secret").cloned().unwrap_or_default(),
826            "refresh_token": params.get("refresh_token").cloned().unwrap_or_default(),
827            "username": params.get("username").cloned().unwrap_or_default(),
828            "password": params.get("password").cloned().unwrap_or_default(),
829            "scope": params.get("scope").cloned().unwrap_or_default(),
830        }),
831    );
832
833    // Call LLM
834    match call_llm(
835        &llm_client,
836        &app_state,
837        server_id,
838        Some(connection_id),
839        &event,
840        &*protocol,
841    )
842    .await
843    {
844        Ok(execution_result) => match first_oauth2_payload(execution_result.protocol_results) {
845            Some((kind, mut payload)) if kind == "token" => {
846                strip_envelope(&mut payload);
847                Log::new(Some(&status_tx)).info(format!(
848                    "OAuth2 /token conn={} decision=model_answer: token issued",
849                    connection_id
850                ));
851                Ok(json_response(200, payload.to_string()))
852            }
853            // A denial must carry an error status. RFC 6749 §5.2 says 400 (401 for
854            // invalid_client); the old code returned the error body with 200, so a
855            // conforming client parsed a refusal as a successful token response.
856            Some((kind, mut payload)) if kind == "error" => {
857                let status = status_or(payload.get("status_code"), 400);
858                strip_envelope(&mut payload);
859                Log::new(Some(&status_tx)).info(format!(
860                    "OAuth2 /token conn={} decision=model_reject: answering {}",
861                    connection_id, status
862                ));
863                Ok(json_response(status, payload.to_string()))
864            }
865            // Fail closed: minting the old hardcoded `ACCESS_TOKEN_123` whenever the model
866            // said nothing meant an LLM outage silently issued working credentials.
867            other => {
868                let log = Log::new(Some(&status_tx));
869                if let Some((kind, _)) = other {
870                    log.error(format!(
871                        "OAuth2 /token conn={} decision=fail_closed_bad_action: got a '{}' \
872                         result, no token issued",
873                        connection_id, kind
874                    ));
875                } else {
876                    log.warn(format!(
877                        "OAuth2 /token conn={} decision=model_silent: no action produced, \
878                         refusing",
879                        connection_id
880                    ));
881                }
882                Ok(json_response(
883                    400,
884                    json!({
885                        "error": "invalid_grant",
886                        "error_description": "no token decision was produced"
887                    })
888                    .to_string(),
889                ))
890            }
891        },
892        Err(e) => {
893            // Never `invalid_grant` here. That code means the presented grant is bad, and a
894            // conforming client reacts by discarding its refresh token and forcing the user to
895            // sign in again - so an LLM outage would have logged every session out
896            // permanently, and the damage would outlive the outage. A backend failure is a
897            // server error, and clients retry those.
898            let (status, code) = oauth2_backend_failure(&e);
899            Log::new(Some(&status_tx)).error(format!(
900                "OAuth2 /token conn={} decision={}: failing with {} {}: {}",
901                connection_id,
902                llm_error_decision(&e),
903                status,
904                code,
905                e
906            ));
907            Ok(json_response(
908                status,
909                json!({
910                    "error": code,
911                    "error_description": oauth2_failure_description(&e)
912                })
913                .to_string(),
914            ))
915        }
916    }
917}
918
919/// Drop the internal routing fields before the payload goes on the wire.
920fn strip_envelope(payload: &mut serde_json::Value) {
921    if let Some(obj) = payload.as_object_mut() {
922        obj.remove(OAUTH2_RESULT_KEY);
923        obj.remove("status_code");
924    }
925}
926
927/// Handle /introspect endpoint (RFC 7662)
928async fn handle_introspect_request(
929    req: Request<Incoming>,
930    connection_id: ConnectionId,
931    server_id: crate::state::ServerId,
932    llm_client: OllamaClient,
933    app_state: Arc<AppState>,
934    status_tx: mpsc::UnboundedSender<String>,
935    protocol: Arc<OAuth2Protocol>,
936) -> Result<Response<Full<Bytes>>, Infallible> {
937    // Parse form body
938    let body_bytes = match read_body_limited(req.into_body()).await {
939        Ok(bytes) => bytes,
940        // Not `{"active": false}`: that is a statement about the token, and nobody looked at
941        // it. 413 says only that the request was refused.
942        Err(()) => {
943            return Ok(json_response(
944                413,
945                json!({
946                    "error": "invalid_request",
947                    "error_description": "request body too large"
948                })
949                .to_string(),
950            ));
951        }
952    };
953
954    let body_str = String::from_utf8_lossy(&body_bytes);
955    let params = parse_query_params(&body_str);
956
957    debug!(
958        "OAuth2 introspect request: token_present={} hint={:?}",
959        params.contains_key("token"),
960        params.get("token_type_hint")
961    );
962
963    // Create LLM event
964    let event = Event::new(
965        &OAUTH2_INTROSPECT_EVENT,
966        json!({
967            "token": params.get("token").cloned().unwrap_or_default(),
968            "token_type_hint": params.get("token_type_hint").cloned().unwrap_or_default(),
969        }),
970    );
971
972    // Call LLM
973    match call_llm(
974        &llm_client,
975        &app_state,
976        server_id,
977        Some(connection_id),
978        &event,
979        &*protocol,
980    )
981    .await
982    {
983        Ok(execution_result) => match first_oauth2_payload(execution_result.protocol_results) {
984            Some((kind, mut payload)) if kind == "introspect" => {
985                // `active` is the model's verdict either way, so this is `model_answer` even
986                // when it reports the token inactive — the refusal below is the *server*
987                // deciding, and the two must not read alike.
988                let active = payload.get("active").and_then(|v| v.as_bool());
989                strip_envelope(&mut payload);
990                Log::new(Some(&status_tx)).info(format!(
991                    "OAuth2 /introspect conn={} decision=model_answer: active={:?}",
992                    connection_id, active
993                ));
994                Ok(json_response(200, payload.to_string()))
995            }
996            Some((kind, mut payload)) if kind == "error" => {
997                let status = status_or(payload.get("status_code"), 400);
998                strip_envelope(&mut payload);
999                Log::new(Some(&status_tx)).info(format!(
1000                    "OAuth2 /introspect conn={} decision=model_reject: answering {}",
1001                    connection_id, status
1002                ));
1003                Ok(json_response(status, payload.to_string()))
1004            }
1005            // Fail closed. The old default was `{"active": true, ...}`, so any token at all
1006            // introspected as valid whenever the model produced nothing — a resource server
1007            // trusting this endpoint would have accepted every bearer token in existence.
1008            other => {
1009                let log = Log::new(Some(&status_tx));
1010                if let Some((kind, _)) = other {
1011                    log.error(format!(
1012                        "OAuth2 /introspect conn={} decision=fail_closed_bad_action: got a '{}' \
1013                         result, reporting inactive",
1014                        connection_id, kind
1015                    ));
1016                } else {
1017                    log.warn(format!(
1018                        "OAuth2 /introspect conn={} decision=model_silent: no action produced, \
1019                         reporting inactive",
1020                        connection_id
1021                    ));
1022                }
1023                Ok(json_response(200, json!({"active": false}).to_string()))
1024            }
1025        },
1026        Err(e) => {
1027            // `{"active": false}` with a 200 is a *statement about the token* - it says the
1028            // authorization server looked and the token is not valid. Nobody looked. It is
1029            // fail-closed, which is why it was the previous answer, but it is
1030            // indistinguishable from the model deciding the token is bad, and a resource
1031            // server has no way to tell "revoked" from "we are broken". A 5xx says only that
1032            // the introspection did not happen, which every resource server already treats as
1033            // "cannot validate" and therefore also refuses.
1034            let (status, code) = oauth2_backend_failure(&e);
1035            Log::new(Some(&status_tx)).error(format!(
1036                "OAuth2 /introspect conn={} decision={}: failing with {} {}: {}",
1037                connection_id,
1038                llm_error_decision(&e),
1039                status,
1040                code,
1041                e
1042            ));
1043            Ok(json_response(
1044                status,
1045                json!({
1046                    "error": code,
1047                    "error_description": oauth2_failure_description(&e)
1048                })
1049                .to_string(),
1050            ))
1051        }
1052    }
1053}
1054
1055/// Handle /revoke endpoint (RFC 7009)
1056async fn handle_revoke_request(
1057    req: Request<Incoming>,
1058    connection_id: ConnectionId,
1059    server_id: crate::state::ServerId,
1060    llm_client: OllamaClient,
1061    app_state: Arc<AppState>,
1062    status_tx: mpsc::UnboundedSender<String>,
1063    protocol: Arc<OAuth2Protocol>,
1064) -> Result<Response<Full<Bytes>>, Infallible> {
1065    // Parse form body
1066    let body_bytes = match read_body_limited(req.into_body()).await {
1067        Ok(bytes) => bytes,
1068        // RFC 7009 §2.2's blanket 200 is for a token the server *processed* and could not
1069        // find. This request was never processed, and a 200 would tell the client the token
1070        // is gone when nothing revoked it.
1071        Err(()) => {
1072            return Ok(json_response(
1073                413,
1074                json!({
1075                    "error": "invalid_request",
1076                    "error_description": "request body too large"
1077                })
1078                .to_string(),
1079            ));
1080        }
1081    };
1082
1083    let body_str = String::from_utf8_lossy(&body_bytes);
1084    let params = parse_query_params(&body_str);
1085
1086    debug!(
1087        "OAuth2 revoke request: token_present={} hint={:?}",
1088        params.contains_key("token"),
1089        params.get("token_type_hint")
1090    );
1091
1092    // Create LLM event
1093    let event = Event::new(
1094        &OAUTH2_REVOKE_EVENT,
1095        json!({
1096            "token": params.get("token").cloned().unwrap_or_default(),
1097            "token_type_hint": params.get("token_type_hint").cloned().unwrap_or_default(),
1098        }),
1099    );
1100
1101    // Call LLM
1102    if let Err(e) = call_llm(
1103        &llm_client,
1104        &app_state,
1105        server_id,
1106        Some(connection_id),
1107        &event,
1108        &*protocol,
1109    )
1110    .await
1111    {
1112        // RFC 7009 2.2.1 anticipates exactly this: "if the server responds with HTTP status
1113        // code 503, the client must assume the token still exists and may retry after a
1114        // reasonable delay". A 200 would tell the client the token is gone when nothing
1115        // processed the request - the model never saw it, so nothing was revoked, and the
1116        // client would stop trying.
1117        let (status, code) = oauth2_backend_failure(&e);
1118        Log::new(Some(&status_tx)).error(format!(
1119            "OAuth2 /revoke conn={} decision={}: failing with {} {}: {}",
1120            connection_id,
1121            llm_error_decision(&e),
1122            status,
1123            code,
1124            e
1125        ));
1126        return Ok(json_response(
1127            status,
1128            json!({
1129                "error": code,
1130                "error_description": oauth2_failure_description(&e)
1131            })
1132            .to_string(),
1133        ));
1134    }
1135
1136    // RFC 7009 §2.2: answer 200 whether or not the token was valid. Nothing the model says
1137    // changes this, which is why OAUTH2_REVOKE_EVENT declares .with_no_actions() — so the
1138    // decision here is the *protocol's*, not the model's, and the tag says so rather than
1139    // claiming a `model_answer` that was never consulted. The server keeps no tokens, so the
1140    // 200 asserts nothing beyond "the request was processed".
1141    Log::new(Some(&status_tx)).info(format!(
1142        "OAuth2 /revoke conn={} decision=protocol_mandated_ok: RFC 7009 §2.2 fixes this reply \
1143         at 200 regardless of what the model said",
1144        connection_id
1145    ));
1146    Ok(build_safe_response(200, [], String::new()))
1147}
1148
1149/// Parse `application/x-www-form-urlencoded` data (query string or POST body).
1150///
1151/// `+` means space in this encoding, which the previous version did not handle: a
1152/// `scope=read+write` body arrived at the model as the literal string `read+write`.
1153/// A pair whose key or value is not valid percent-encoding is skipped rather than
1154/// collapsed into an empty-string key, which previously made two malformed pairs
1155/// overwrite each other.
1156fn parse_query_params(query: &str) -> HashMap<String, String> {
1157    let mut params = HashMap::new();
1158    for pair in query.split('&') {
1159        let Some((key, value)) = pair.split_once('=') else {
1160            continue;
1161        };
1162        let (key, value) = (key.replace('+', " "), value.replace('+', " "));
1163        let (Ok(key), Ok(value)) = (urlencoding::decode(&key), urlencoding::decode(&value)) else {
1164            debug!("OAuth2: skipping malformed form parameter {pair:?}");
1165            continue;
1166        };
1167        params.insert(key.into_owned(), value.into_owned());
1168    }
1169    params
1170}