pub mod actions;
use std::collections::HashMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::{Body, Incoming};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use serde_json::json;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::server::oauth2::actions::{
OAuth2Protocol, OAUTH2_AUTHORIZE_EVENT, OAUTH2_INTROSPECT_EVENT, OAUTH2_RESULT_KEY,
OAUTH2_REVOKE_EVENT, OAUTH2_TOKEN_EVENT,
};
use crate::state::app_state::AppState;
pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
async fn read_body_limited(body: Incoming) -> Result<Bytes, ()> {
match http_body_util::Limited::new(body, MAX_REQUEST_BYTES)
.collect()
.await
{
Ok(collected) => Ok(collected.to_bytes()),
Err(e) => {
warn!(
"OAuth2 decision=refused_body_too_large: rejecting request body over \
{MAX_REQUEST_BYTES} bytes: {e}"
);
Err(())
}
}
}
fn redact_params(params: &HashMap<String, String>) -> Vec<(&str, &str)> {
const SECRET: &[&str] = &[
"client_secret",
"password",
"token",
"code",
"refresh_token",
"assertion",
];
let mut pairs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| {
if SECRET.contains(&k.as_str()) {
(k.as_str(), if v.is_empty() { "" } else { "<redacted>" })
} else {
(k.as_str(), v.as_str())
}
})
.collect();
pairs.sort_unstable();
pairs
}
fn status_or(value: Option<&serde_json::Value>, default: u16) -> u16 {
match value.and_then(|v| v.as_u64()) {
Some(raw) => u16::try_from(raw)
.ok()
.filter(|s| (100..=599).contains(s))
.unwrap_or_else(|| {
warn!("OAuth2: ignoring out-of-range status_code {raw}, using {default}");
default
}),
None => default,
}
}
fn build_safe_response(
status: u16,
headers: impl IntoIterator<Item = (String, String)>,
body: String,
) -> Response<Full<Bytes>> {
let status_code = StatusCode::from_u16(status).unwrap_or_else(|_| {
error!("OAuth2: invalid HTTP status {status}, sending 500 instead");
StatusCode::INTERNAL_SERVER_ERROR
});
let mut builder = Response::builder().status(status_code);
for (name, value) in headers {
match (
hyper::header::HeaderName::from_bytes(name.as_bytes()),
hyper::header::HeaderValue::from_str(&value),
) {
(Ok(n), Ok(v)) => builder = builder.header(n, v),
_ => warn!("OAuth2: dropping invalid response header {name:?}"),
}
}
builder
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|e| {
error!("OAuth2: failed to build response ({e}), sending bare 500");
let mut fallback = Response::new(Full::new(Bytes::from_static(
br#"{"error":"server_error"}"#.as_slice(),
)));
*fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
fallback
})
}
fn oauth2_backend_failure(err: &anyhow::Error) -> (u16, &'static str) {
if crate::llm::is_overload_error(err) {
(503, "temporarily_unavailable")
} else {
(500, "server_error")
}
}
fn oauth2_failure_description(err: &anyhow::Error) -> &'static str {
crate::utils::WireFailure::classify(err).prefixed_text()
}
fn llm_error_decision(err: &anyhow::Error) -> &'static str {
match crate::utils::WireFailure::classify(err) {
crate::utils::WireFailure::Overloaded => "fail_closed_llm_overloaded",
crate::utils::WireFailure::Unavailable => "fail_closed_llm_error",
}
}
fn json_response(status: u16, body: String) -> Response<Full<Bytes>> {
build_safe_response(
status,
[
("content-type".to_string(), "application/json".to_string()),
("cache-control".to_string(), "no-store".to_string()),
("pragma".to_string(), "no-cache".to_string()),
],
body,
)
}
fn first_oauth2_payload(
results: Vec<crate::llm::ActionResult>,
) -> Option<(String, serde_json::Value)> {
results.into_iter().find_map(|result| {
let crate::llm::ActionResult::Output(bytes) = result else {
return None;
};
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let kind = value.get(OAUTH2_RESULT_KEY)?.as_str()?.to_string();
Some((kind, value))
})
}
pub struct OAuth2Server;
impl OAuth2Server {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
) -> anyhow::Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
Log::new(Some(&status_tx)).info(format!("OAuth2 server listening on {}", local_addr));
let protocol = Arc::new(OAuth2Protocol::new());
let task_registrar = app_state.clone();
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"OAuth2",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
info!("OAuth2 connection {} from {}", connection_id, remote_addr);
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
use serde_json::json;
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr,
local_addr: local_addr_conn,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::new(json!({
"recent_requests": []
})),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm_client_clone = llm_client.clone();
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let protocol_clone = protocol.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
let spoke = matches!(
tokio::time::timeout(
FIRST_BYTE_READ_TIMEOUT,
stream.peek(&mut [0u8; 1]),
)
.await,
Ok(Ok(n)) if n > 0
);
let io = TokioIo::new(stream);
let status_for_service = status_tx_clone.clone();
let app_state_for_service = app_state_clone.clone();
let activity = std::sync::Arc::new(
crate::server::accept_bounded::ConnectionActivity::new(),
);
let activity_for_service = std::sync::Arc::clone(&activity);
let service = service_fn(move |req: Request<Incoming>| {
let llm_clone = llm_client_clone.clone();
let state_clone = app_state_for_service.clone();
let status_clone = status_for_service.clone();
let protocol_clone = protocol_clone.clone();
let activity = std::sync::Arc::clone(&activity_for_service);
async move {
let _busy = activity.busy();
handle_oauth2_request(
req,
connection_id,
server_id,
llm_clone,
state_clone,
status_clone,
protocol_clone,
)
.await
}
});
if !spoke {
Log::new(Some(&status_tx_clone)).debug(format!(
"OAuth2 peer {} sent nothing for {}s; closing before \
any request",
remote_addr,
FIRST_BYTE_READ_TIMEOUT.as_secs()
));
} else {
let conn = http1::Builder::new().serve_connection(io, service);
tokio::pin!(conn);
tokio::select! {
result = &mut conn => {
if let Err(err) = result {
error!(
"Error serving OAuth2 connection: \
{:?}",
err
);
}
}
_ = crate::server::accept_bounded::watch_idle(
std::sync::Arc::clone(&activity),
IDLE_BETWEEN_REQUESTS_TIMEOUT,
) => {
Log::new(Some(&status_tx_clone)).debug(format!(
"OAuth2 connection {} idle for {}s; closing",
connection_id,
IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
));
}
}
}
app_state_clone
.close_connection_on_server(server_id, connection_id)
.await;
Log::new(Some(&status_tx_clone))
.info(format!("OAuth2 connection {connection_id} closed"));
let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
})
.await;
}
Err(e) => {
error!("Failed to accept OAuth2 connection: {}", e);
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
async fn handle_oauth2_request(
req: Request<Incoming>,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<OAuth2Protocol>,
) -> Result<Response<Full<Bytes>>, Infallible> {
let method = req.method().clone();
let uri = req.uri().clone();
let path = uri.path();
Log::new(Some(&status_tx)).debug(format!("OAuth2 request: {} {}", method, path));
app_state
.update_connection_stats(server_id, connection_id, None, None, Some(1), None)
.await;
let response = match (method.clone(), path) {
(Method::GET, "/authorize") | (Method::POST, "/authorize") => {
handle_authorize_request(
req,
connection_id,
server_id,
llm_client,
app_state.clone(),
status_tx.clone(),
protocol,
)
.await
}
(Method::POST, "/token") => {
handle_token_request(
req,
connection_id,
server_id,
llm_client,
app_state.clone(),
status_tx.clone(),
protocol,
)
.await
}
(Method::POST, "/introspect") => {
handle_introspect_request(
req,
connection_id,
server_id,
llm_client,
app_state.clone(),
status_tx.clone(),
protocol,
)
.await
}
(Method::POST, "/revoke") => {
handle_revoke_request(
req,
connection_id,
server_id,
llm_client,
app_state.clone(),
status_tx.clone(),
protocol,
)
.await
}
_ => {
debug!(
"OAuth2 conn={} {} {} decision=unknown_endpoint: 404, no LLM call",
connection_id, method, path
);
Ok(json_response(
404,
json!({
"error": "invalid_request",
"error_description": "Unknown endpoint"
})
.to_string(),
))
}
};
let body_size = response
.as_ref()
.ok()
.map(|resp| resp.body().size_hint().exact().unwrap_or(0))
.unwrap_or(0);
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(body_size),
None,
Some(1),
)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
response
}
async fn handle_authorize_request(
req: Request<Incoming>,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<OAuth2Protocol>,
) -> Result<Response<Full<Bytes>>, Infallible> {
let uri = req.uri().clone();
let method = req.method().clone();
let params = if method == Method::GET {
parse_query_params(uri.query().unwrap_or(""))
} else {
match read_body_limited(req.into_body()).await {
Ok(body_bytes) => parse_query_params(&String::from_utf8_lossy(&body_bytes)),
Err(()) => {
return Ok(json_response(
413,
json!({
"error": "invalid_request",
"error_description": "request body too large"
})
.to_string(),
));
}
}
};
Log::new(Some(&status_tx)).debug(format!(
"OAuth2 authorize request: {:?}",
redact_params(¶ms)
));
let event = Event::new(
&OAUTH2_AUTHORIZE_EVENT,
json!({
"response_type": params.get("response_type").cloned().unwrap_or_default(),
"client_id": params.get("client_id").cloned().unwrap_or_default(),
"redirect_uri": params.get("redirect_uri").cloned().unwrap_or_default(),
"scope": params.get("scope").cloned().unwrap_or_default(),
"state": params.get("state").cloned().unwrap_or_default(),
}),
);
match call_llm(
&llm_client,
&app_state,
server_id,
Some(connection_id),
&event,
&*protocol,
)
.await
{
Ok(execution_result) => {
let redirect_uri = params
.get("redirect_uri")
.cloned()
.unwrap_or_else(|| "urn:ietf:wg:oauth:2.0:oob".to_string());
let request_state = params.get("state").cloned();
match first_oauth2_payload(execution_result.protocol_results) {
Some((kind, payload)) if kind == "authorize" => {
let Some(code) = payload.get("code").and_then(|v| v.as_str()) else {
Log::new(Some(&status_tx)).error(format!(
"OAuth2 /authorize conn={} decision=fail_closed_bad_action: the \
authorize action carried no code",
connection_id
));
return Ok(authorize_redirect(
&redirect_uri,
&[
("error", "server_error"),
("error_description", "authorization action produced no code"),
],
request_state.as_deref(),
));
};
Log::new(Some(&status_tx)).info(format!(
"OAuth2 /authorize conn={} decision=model_answer: approved, redirecting \
to {}",
connection_id, redirect_uri
));
Ok(authorize_redirect(
&redirect_uri,
&[("code", code)],
payload
.get("state")
.and_then(|v| v.as_str())
.or(request_state.as_deref()),
))
}
Some((kind, payload)) if kind == "error" => {
let error = payload
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("access_denied");
let description = payload.get("error_description").and_then(|v| v.as_str());
Log::new(Some(&status_tx)).info(format!(
"OAuth2 /authorize conn={} decision=model_reject: {}",
connection_id, error
));
let mut pairs = vec![("error", error)];
if let Some(d) = description {
pairs.push(("error_description", d));
}
Ok(authorize_redirect(
&redirect_uri,
&pairs,
request_state.as_deref(),
))
}
other => {
let log = Log::new(Some(&status_tx));
if let Some((kind, _)) = other {
log.error(format!(
"OAuth2 /authorize conn={} decision=fail_closed_bad_action: got a \
'{}' result, which belongs to another endpoint",
connection_id, kind
));
} else {
log.warn(format!(
"OAuth2 /authorize conn={} decision=model_silent: no action \
produced, denying",
connection_id
));
}
Ok(authorize_redirect(
&redirect_uri,
&[
("error", "server_error"),
(
"error_description",
"no authorization decision was produced",
),
],
request_state.as_deref(),
))
}
}
}
Err(e) => {
let (status, code) = oauth2_backend_failure(&e);
Log::new(Some(&status_tx)).error(format!(
"OAuth2 /authorize conn={} decision={}: failing with {} {}: {}",
connection_id,
llm_error_decision(&e),
status,
code,
e
));
Ok(json_response(
status,
json!({
"error": code,
"error_description": oauth2_failure_description(&e)
})
.to_string(),
))
}
}
}
fn authorize_redirect(
redirect_uri: &str,
pairs: &[(&str, &str)],
state: Option<&str>,
) -> Response<Full<Bytes>> {
let mut query: Vec<String> = pairs
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect();
if let Some(state) = state.filter(|s| !s.is_empty()) {
query.push(format!("state={}", urlencoding::encode(state)));
}
let separator = if redirect_uri.contains('?') { "&" } else { "?" };
let location = format!("{}{}{}", redirect_uri, separator, query.join("&"));
build_safe_response(302, [("location".to_string(), location)], String::new())
}
async fn handle_token_request(
req: Request<Incoming>,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<OAuth2Protocol>,
) -> Result<Response<Full<Bytes>>, Infallible> {
let body_bytes = match read_body_limited(req.into_body()).await {
Ok(bytes) => bytes,
Err(()) => {
return Ok(json_response(
413,
json!({
"error": "invalid_request",
"error_description": "request body too large"
})
.to_string(),
));
}
};
let body_str = String::from_utf8_lossy(&body_bytes);
let params = parse_query_params(&body_str);
Log::new(Some(&status_tx)).debug(format!(
"OAuth2 token request: {:?}",
redact_params(¶ms)
));
let event = Event::new(
&OAUTH2_TOKEN_EVENT,
json!({
"grant_type": params.get("grant_type").cloned().unwrap_or_default(),
"code": params.get("code").cloned().unwrap_or_default(),
"redirect_uri": params.get("redirect_uri").cloned().unwrap_or_default(),
"client_id": params.get("client_id").cloned().unwrap_or_default(),
"client_secret": params.get("client_secret").cloned().unwrap_or_default(),
"refresh_token": params.get("refresh_token").cloned().unwrap_or_default(),
"username": params.get("username").cloned().unwrap_or_default(),
"password": params.get("password").cloned().unwrap_or_default(),
"scope": params.get("scope").cloned().unwrap_or_default(),
}),
);
match call_llm(
&llm_client,
&app_state,
server_id,
Some(connection_id),
&event,
&*protocol,
)
.await
{
Ok(execution_result) => match first_oauth2_payload(execution_result.protocol_results) {
Some((kind, mut payload)) if kind == "token" => {
strip_envelope(&mut payload);
Log::new(Some(&status_tx)).info(format!(
"OAuth2 /token conn={} decision=model_answer: token issued",
connection_id
));
Ok(json_response(200, payload.to_string()))
}
Some((kind, mut payload)) if kind == "error" => {
let status = status_or(payload.get("status_code"), 400);
strip_envelope(&mut payload);
Log::new(Some(&status_tx)).info(format!(
"OAuth2 /token conn={} decision=model_reject: answering {}",
connection_id, status
));
Ok(json_response(status, payload.to_string()))
}
other => {
let log = Log::new(Some(&status_tx));
if let Some((kind, _)) = other {
log.error(format!(
"OAuth2 /token conn={} decision=fail_closed_bad_action: got a '{}' \
result, no token issued",
connection_id, kind
));
} else {
log.warn(format!(
"OAuth2 /token conn={} decision=model_silent: no action produced, \
refusing",
connection_id
));
}
Ok(json_response(
400,
json!({
"error": "invalid_grant",
"error_description": "no token decision was produced"
})
.to_string(),
))
}
},
Err(e) => {
let (status, code) = oauth2_backend_failure(&e);
Log::new(Some(&status_tx)).error(format!(
"OAuth2 /token conn={} decision={}: failing with {} {}: {}",
connection_id,
llm_error_decision(&e),
status,
code,
e
));
Ok(json_response(
status,
json!({
"error": code,
"error_description": oauth2_failure_description(&e)
})
.to_string(),
))
}
}
}
fn strip_envelope(payload: &mut serde_json::Value) {
if let Some(obj) = payload.as_object_mut() {
obj.remove(OAUTH2_RESULT_KEY);
obj.remove("status_code");
}
}
async fn handle_introspect_request(
req: Request<Incoming>,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<OAuth2Protocol>,
) -> Result<Response<Full<Bytes>>, Infallible> {
let body_bytes = match read_body_limited(req.into_body()).await {
Ok(bytes) => bytes,
Err(()) => {
return Ok(json_response(
413,
json!({
"error": "invalid_request",
"error_description": "request body too large"
})
.to_string(),
));
}
};
let body_str = String::from_utf8_lossy(&body_bytes);
let params = parse_query_params(&body_str);
debug!(
"OAuth2 introspect request: token_present={} hint={:?}",
params.contains_key("token"),
params.get("token_type_hint")
);
let event = Event::new(
&OAUTH2_INTROSPECT_EVENT,
json!({
"token": params.get("token").cloned().unwrap_or_default(),
"token_type_hint": params.get("token_type_hint").cloned().unwrap_or_default(),
}),
);
match call_llm(
&llm_client,
&app_state,
server_id,
Some(connection_id),
&event,
&*protocol,
)
.await
{
Ok(execution_result) => match first_oauth2_payload(execution_result.protocol_results) {
Some((kind, mut payload)) if kind == "introspect" => {
let active = payload.get("active").and_then(|v| v.as_bool());
strip_envelope(&mut payload);
Log::new(Some(&status_tx)).info(format!(
"OAuth2 /introspect conn={} decision=model_answer: active={:?}",
connection_id, active
));
Ok(json_response(200, payload.to_string()))
}
Some((kind, mut payload)) if kind == "error" => {
let status = status_or(payload.get("status_code"), 400);
strip_envelope(&mut payload);
Log::new(Some(&status_tx)).info(format!(
"OAuth2 /introspect conn={} decision=model_reject: answering {}",
connection_id, status
));
Ok(json_response(status, payload.to_string()))
}
other => {
let log = Log::new(Some(&status_tx));
if let Some((kind, _)) = other {
log.error(format!(
"OAuth2 /introspect conn={} decision=fail_closed_bad_action: got a '{}' \
result, reporting inactive",
connection_id, kind
));
} else {
log.warn(format!(
"OAuth2 /introspect conn={} decision=model_silent: no action produced, \
reporting inactive",
connection_id
));
}
Ok(json_response(200, json!({"active": false}).to_string()))
}
},
Err(e) => {
let (status, code) = oauth2_backend_failure(&e);
Log::new(Some(&status_tx)).error(format!(
"OAuth2 /introspect conn={} decision={}: failing with {} {}: {}",
connection_id,
llm_error_decision(&e),
status,
code,
e
));
Ok(json_response(
status,
json!({
"error": code,
"error_description": oauth2_failure_description(&e)
})
.to_string(),
))
}
}
}
async fn handle_revoke_request(
req: Request<Incoming>,
connection_id: ConnectionId,
server_id: crate::state::ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<OAuth2Protocol>,
) -> Result<Response<Full<Bytes>>, Infallible> {
let body_bytes = match read_body_limited(req.into_body()).await {
Ok(bytes) => bytes,
Err(()) => {
return Ok(json_response(
413,
json!({
"error": "invalid_request",
"error_description": "request body too large"
})
.to_string(),
));
}
};
let body_str = String::from_utf8_lossy(&body_bytes);
let params = parse_query_params(&body_str);
debug!(
"OAuth2 revoke request: token_present={} hint={:?}",
params.contains_key("token"),
params.get("token_type_hint")
);
let event = Event::new(
&OAUTH2_REVOKE_EVENT,
json!({
"token": params.get("token").cloned().unwrap_or_default(),
"token_type_hint": params.get("token_type_hint").cloned().unwrap_or_default(),
}),
);
if let Err(e) = call_llm(
&llm_client,
&app_state,
server_id,
Some(connection_id),
&event,
&*protocol,
)
.await
{
let (status, code) = oauth2_backend_failure(&e);
Log::new(Some(&status_tx)).error(format!(
"OAuth2 /revoke conn={} decision={}: failing with {} {}: {}",
connection_id,
llm_error_decision(&e),
status,
code,
e
));
return Ok(json_response(
status,
json!({
"error": code,
"error_description": oauth2_failure_description(&e)
})
.to_string(),
));
}
Log::new(Some(&status_tx)).info(format!(
"OAuth2 /revoke conn={} decision=protocol_mandated_ok: RFC 7009 §2.2 fixes this reply \
at 200 regardless of what the model said",
connection_id
));
Ok(build_safe_response(200, [], String::new()))
}
fn parse_query_params(query: &str) -> HashMap<String, String> {
let mut params = HashMap::new();
for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
let (key, value) = (key.replace('+', " "), value.replace('+', " "));
let (Ok(key), Ok(value)) = (urlencoding::decode(&key), urlencoding::decode(&value)) else {
debug!("OAuth2: skipping malformed form parameter {pair:?}");
continue;
};
params.insert(key.into_owned(), value.into_owned());
}
params
}