use std::net::Ipv4Addr;
use anyhow::{bail, Context};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
const CALLBACK_PATH: &str = "/oauth/callback";
const REQUEST_READ_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
const MAX_REQUEST_BYTES: usize = 16 * 1024;
const CHUNK_BYTES: usize = 2048;
const SUCCESS_BODY: &str = "Rho is authorized for this MCP server. You can close this tab.";
const FAILURE_BODY: &str = "Rho could not read this authorization response.";
const IGNORED_BODY: &str = "Not the Rho authorization callback.";
const STATE_MISMATCH_BODY: &str =
"This authorization response did not match the pending Rho login. Waiting for the correct one.";
pub(super) struct LoopbackRedirect {
listener: TcpListener,
redirect_uri: String,
}
impl LoopbackRedirect {
pub(super) async fn bind() -> anyhow::Result<Self> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.context("could not bind the local MCP OAuth callback listener")?;
let address = listener.local_addr()?;
Ok(Self {
redirect_uri: format!("http://{address}{CALLBACK_PATH}"),
listener,
})
}
pub(super) fn redirect_uri(&self) -> &str {
&self.redirect_uri
}
pub(super) async fn wait_for_redirect(&self, expected_state: &str) -> anyhow::Result<String> {
loop {
let (mut stream, _) = self
.listener
.accept()
.await
.context("MCP OAuth callback listener failed")?;
let request = match read_request(&mut stream).await {
Ok(request) => request,
Err(error) => {
tracing::debug!(error = %error, "discarding unreadable MCP OAuth callback request");
respond(&mut stream, CallbackVerdict::Unreadable).await;
continue;
}
};
match callback_target(&request, expected_state) {
Ok(target) => {
respond(&mut stream, CallbackVerdict::Accepted).await;
return Ok(format!("http://{}{target}", self.listener.local_addr()?));
}
Err(CallbackReject::WrongState) => {
tracing::debug!(
"ignoring MCP OAuth callback with a non-matching state parameter"
);
respond(&mut stream, CallbackVerdict::WrongState).await;
}
Err(CallbackReject::NotOurs(error)) => {
tracing::debug!(error = %error, "ignoring non-callback request on the MCP OAuth listener");
respond(&mut stream, CallbackVerdict::NotOurs).await;
}
}
}
}
}
#[derive(Clone, Copy)]
enum CallbackVerdict {
Accepted,
Unreadable,
NotOurs,
WrongState,
}
#[derive(Debug)]
pub(super) enum CallbackReject {
WrongState,
NotOurs(anyhow::Error),
}
pub(super) fn callback_target<'a>(
request: &'a str,
expected_state: &str,
) -> Result<&'a str, CallbackReject> {
let request_line = request.lines().next().unwrap_or_default();
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default();
let target = parts.next().unwrap_or_default();
if method != "GET" {
return Err(CallbackReject::NotOurs(anyhow::anyhow!(
"callback request used {method} rather than GET"
)));
}
let path = target.split_once('?').map_or(target, |(path, _)| path);
if path != CALLBACK_PATH {
return Err(CallbackReject::NotOurs(anyhow::anyhow!(
"callback request targeted `{path}` rather than `{CALLBACK_PATH}`"
)));
}
let callback_url = format!("http://127.0.0.1{target}");
let url = url::Url::parse(&callback_url).map_err(|error| {
CallbackReject::NotOurs(anyhow::anyhow!(
"callback target was not a valid URL: {error}"
))
})?;
let mut state = None;
for (name, value) in url.query_pairs() {
if name == "state" {
state = Some(value);
}
}
match state.as_deref() {
None => Err(CallbackReject::NotOurs(anyhow::anyhow!(
"callback request carried no `state` parameter"
))),
Some(value) if value == expected_state => Ok(target),
Some(_) => Err(CallbackReject::WrongState),
}
}
pub(super) fn state_from_authorization_url(auth_url: &str) -> anyhow::Result<String> {
let url = url::Url::parse(auth_url).context("authorization URL was not a valid URL")?;
url.query_pairs()
.find(|(name, _)| name == "state")
.map(|(_, value)| value.into_owned())
.context("authorization URL carried no `state` parameter")
}
async fn respond(stream: &mut TcpStream, verdict: CallbackVerdict) {
let (status, body) = match verdict {
CallbackVerdict::Accepted => ("200 OK", SUCCESS_BODY),
CallbackVerdict::Unreadable => ("400 Bad Request", FAILURE_BODY),
CallbackVerdict::NotOurs => ("404 Not Found", IGNORED_BODY),
CallbackVerdict::WrongState => ("400 Bad Request", STATE_MISMATCH_BODY),
};
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
if let Err(error) = stream.write_all(response.as_bytes()).await {
tracing::debug!(error = %error, "could not answer the MCP OAuth callback request");
}
}
async fn read_request(stream: &mut TcpStream) -> anyhow::Result<String> {
let read = async {
let mut request = Vec::new();
let mut chunk = [0_u8; CHUNK_BYTES];
loop {
let read = stream.read(&mut chunk).await?;
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
if request.len() > MAX_REQUEST_BYTES {
bail!("callback request exceeded {MAX_REQUEST_BYTES} bytes");
}
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
Ok(String::from_utf8_lossy(&request).into_owned())
};
tokio::time::timeout(REQUEST_READ_BUDGET, read)
.await
.context("callback request stalled before its headers arrived")?
}
#[cfg(test)]
#[path = "callback_tests.rs"]
mod tests;