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(¶ms)
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(¶ms)
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}